Build an accessible React password show or hide toggle

Build an accessible React password field with a show or hide toggle, covering UX, a11y, TypeScript, SSR, and integrations with MUI and Chakra.

ASOasis
7 min read
Build an accessible React password show or hide toggle

Image used for representation purposes only.

Why a show or hide toggle matters

A password reveal control looks simple, yet it touches UX, accessibility, privacy, internationalization, and even server rendering. In this guide you will build a robust React password input with a show or hide toggle, understand the trade‑offs of controlled vs uncontrolled inputs, and learn how to integrate the pattern with popular UI libraries.

UX and accessibility essentials

A good toggle does more than swap the input type.

  • Use a real <button type='button'> for the toggle; never use a link or a span with click handlers.
  • Provide an accessible name that updates with state, for example Show password and Hide password.
  • Add aria-pressed to communicate the toggle state to assistive tech.
  • Preserve label association with label and htmlFor.
  • Keep the toggle reachable with keyboard: Tab to focus, Enter or Space to activate.
  • Do not rely on an icon alone; combine an icon with visible text or a visually hidden label.
  • Respect user privacy; default to hidden and consider opt‑in auto‑hide after inactivity.

Minimal, accessible implementation (controlled)

This example keeps the input controlled by React state, which is convenient for form validation and integrations. It also demonstrates clear, accessible button text and aria-pressed.

import React from 'react';

export function PasswordInputControlled() {
  const [value, setValue] = React.useState('');
  const [revealed, setRevealed] = React.useState(false);

  return (
    <div className='field'>
      <label htmlFor='password'>Password</label>
      <div className='input-group'>
        <input
          id='password'
          name='password'
          type={revealed ? 'text' : 'password'}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          autoComplete='current-password'
          inputMode='text'
          spellCheck={false}
          aria-describedby='password-help'
        />
        <button
          type='button'
          className='toggle'
          onClick={() => setRevealed((r) => !r)}
          aria-pressed={revealed}
          aria-label={revealed ? 'Hide password' : 'Show password'}
        >
          {revealed ? 'Hide' : 'Show'}
        </button>
      </div>
      <p id='password-help' className='help'>
        Use at least 12 characters with a mix of letters, numbers, and symbols.
      </p>
    </div>
  );
}

Optional, minimal CSS to align the toggle with the input:

.field { max-width: 420px; }
.input-group { position: relative; display: flex; }
.input-group input { flex: 1; padding-right: 4rem; }
.input-group .toggle {
  position: absolute;
  right: 0.5rem;
  top: 50%;
  transform: translateY(-50%);
  background: none; border: 0; cursor: pointer;
  padding: 0.25rem 0.5rem; color: #444;
}
.input-group .toggle:focus { outline: 2px solid #2684ff; outline-offset: 2px; }

Reusable component with TypeScript and auto‑hide

This component supports both controlled and uncontrolled usage and can auto‑hide after a configurable period. It forwards a ref for form libraries.

import React, { forwardRef } from 'react';

type PasswordFieldProps = {
  id?: string;
  name?: string;
  label?: string;
  value?: string;
  defaultValue?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
  autoComplete?: 'current-password' | 'new-password';
  revealText?: string; // i18n
  hideText?: string;   // i18n
  autoHideAfterMs?: number; // auto conceal delay
  disabled?: boolean;
};

export const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(
  (
    {
      id = 'password',
      name = 'password',
      label = 'Password',
      value,
      defaultValue,
      onChange,
      autoComplete = 'current-password',
      revealText = 'Show password',
      hideText = 'Hide password',
      autoHideAfterMs,
      disabled,
    },
    ref
  ) => {
    const [revealed, setRevealed] = React.useState(false);

    // Optional auto-hide timer
    React.useEffect(() => {
      if (!revealed || !autoHideAfterMs) return;
      const t = setTimeout(() => setRevealed(false), autoHideAfterMs);
      return () => clearTimeout(t);
    }, [revealed, autoHideAfterMs]);

    return (
      <div className='field'>
        <label htmlFor={id}>{label}</label>
        <div className='input-group'>
          <input
            ref={ref}
            id={id}
            name={name}
            type={revealed ? 'text' : 'password'}
            value={value}
            defaultValue={defaultValue}
            onChange={onChange}
            autoComplete={autoComplete}
            inputMode='text'
            spellCheck={false}
            disabled={disabled}
          />
          <button
            type='button'
            className='toggle'
            onClick={() => setRevealed((r) => !r)}
            aria-pressed={revealed}
            aria-label={revealed ? hideText : revealText}
            disabled={disabled}
          >
            {revealed ? 'Hide' : 'Show'}
          </button>
        </div>
      </div>
    );
  }
);

Usage examples:

// Controlled
const [pwd, setPwd] = React.useState('');
<PasswordField value={pwd} onChange={(e) => setPwd(e.target.value)} />;

// Uncontrolled
<PasswordField defaultValue='' />;

// With auto-hide after 10 seconds
<PasswordField autoHideAfterMs={10_000} />;

// i18n labels
<PasswordField revealText='Mostrar contraseña' hideText='Ocultar contraseña' />;

Uncontrolled variant for reduced in‑memory exposure

If you prefer not to mirror the password in React state, use an uncontrolled input. The reveal toggle still works because it only changes the type attribute.

function PasswordUncontrolled() {
  const inputRef = React.useRef<HTMLInputElement>(null);
  const [revealed, setRevealed] = React.useState(false);

  const submit = () => {
    const value = inputRef.current?.value ?? '';
    // send value to your API securely
  };

  return (
    <div className='field'>
      <label htmlFor='pwd-u'>Password</label>
      <div className='input-group'>
        <input id='pwd-u' ref={inputRef} type={revealed ? 'text' : 'password'} autoComplete='current-password' />
        <button type='button' aria-pressed={revealed} aria-label={revealed ? 'Hide password' : 'Show password'} onClick={() => setRevealed(!revealed)}>
          {revealed ? 'Hide' : 'Show'}
        </button>
      </div>
      <button onClick={submit}>Sign in</button>
    </div>
  );
}

SSR and hydration notes

  • Default the toggle to concealed on the server to avoid mismatches. If you must derive the initial state from the client (for example, user preference), set a concealed default and update after useEffect runs.
  • Icon‑only buttons can shift on hydration if font icons load late; prefer inline SVG.
// Safe default on server
const [revealed, setRevealed] = React.useState(false); // matches server and client

You can keep the same logic and wire it to your component library of choice.

Material UI (MUI)

import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';

function MuiPassword() {
  const [revealed, setRevealed] = React.useState(false);

  return (
    <TextField
      label='Password'
      type={revealed ? 'text' : 'password'}
      autoComplete='current-password'
      InputProps={{
        endAdornment: (
          <InputAdornment position='end'>
            <IconButton
              onClick={() => setRevealed((r) => !r)}
              aria-label={revealed ? 'Hide password' : 'Show password'}
              aria-pressed={revealed}
              edge='end'
            >
              {revealed ? <VisibilityOff /> : <Visibility />}
            </IconButton>
          </InputAdornment>
        ),
      }}
      variant='outlined'
    />
  );
}

Chakra UI

import { Input, InputGroup, InputRightElement, IconButton, VisuallyHidden } from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';

function ChakraPassword() {
  const [revealed, setRevealed] = React.useState(false);
  return (
    <InputGroup>
      <Input type={revealed ? 'text' : 'password'} autoComplete='current-password' placeholder='Password' />
      <InputRightElement>
        <IconButton
          aria-label={revealed ? 'Hide password' : 'Show password'}
          aria-pressed={revealed}
          onClick={() => setRevealed((r) => !r)}
          icon={revealed ? <ViewOffIcon /> : <ViewIcon />}
          variant='ghost'
        />
        <VisuallyHidden>{revealed ? 'Hide password' : 'Show password'}</VisuallyHidden>
      </InputRightElement>
    </InputGroup>
  );
}

Security and privacy considerations

  • Revealing switches the visual mask only. It does not change whether scripts can read the value; assume any client script with access can read it. Focus on preventing shoulder surfing and screen capture.
  • Default to hidden on load. Make reveal opt‑in and consider an optional auto‑hide timer.
  • Do not disable paste; it harms usability and password manager flows without raising actual security.
  • Use the appropriate autoComplete value: current-password for sign‑in, new-password for sign‑up or change flows.
  • Avoid logging input values or echoing them in error boundaries.
  • Ensure the toggle does not submit the form: the button must be type='button'.

Internationalization tips

  • Do not hardcode English. Parameterize the reveal and hide labels or read them from your i18n layer.
  • Keep labels concise; they are read frequently by screen readers.
  • If you swap the icon when revealed, maintain consistent button width to prevent layout shift with longer translations.

Testing the toggle with React Testing Library

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PasswordField } from './PasswordField';

test('toggles input type and accessible name', async () => {
  render(<PasswordField />);
  const button = screen.getByRole('button', { name: /show password/i });
  const input = screen.getByLabelText(/password/i) as HTMLInputElement;

  expect(input.type).toBe('password');
  await userEvent.click(button);
  expect(input.type).toBe('text');
  expect(button).toHaveAttribute('aria-pressed', 'true');
  expect(button).toHaveAccessibleName(/hide password/i);
});

Common pitfalls to avoid

  • Icon only with no accessible name. Always provide a readable label.
  • Using a checkbox instead of a button; the action is a transient reveal, not a persisted setting.
  • Forgetting type='button' on the toggle, which can cause accidental form submissions.
  • Managing visibility entirely in CSS (for example, text‑security tricks) instead of switching the type attribute.
  • Placing the toggle after the submit button in the tab order; position it as an end‑adornment of the field.

Quick checklist

  • Button with changing accessible name and aria-pressed.
  • Keyboard operable and focus visible.
  • Default concealed; optional auto‑hide.
  • Works with password managers via correct autoComplete.
  • Tested for both controlled and uncontrolled use.

Wrap‑up

A well‑designed show or hide password toggle is a small but high‑impact feature. By combining clear labels, proper semantics, and thoughtful privacy defaults, you can deliver a field that is pleasant to use, works with assistive technology, and integrates cleanly across your design system. The snippets above give you everything you need to implement it in plain React or with libraries like MUI and Chakra, all while keeping accessibility and security front and center.

Related Posts