Reactで、パスワードの表示と非表示を切り替える方法

​​React​HTML,Playwright,TypeScript,アクセシビリティ

パスワードの表示と非表示は、input の type を "password" と "text" で切り替えるだけで作れます。React では「見せているかどうか」を1つの state で持ち、type={visible ? 'text' : 'password'} と書きます。入力した値は、切り替えても消えません。

注意が要るのは、切り替えのボタンのほうです。type="button" を書き忘れるとフォームが送信されること、読み上げのための名前と aria-pressed の組み合わせ、ボタンを押したあとのフォーカスの行き先を、検証環境で確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。スクリーンリーダーでの読み上げ、スマートフォンのソフトウェアキーボード、Firefox と Safari、ブラウザやパスワードマネージャーによる自動入力は試していません。読み上げに使われる名前は、Playwright でアクセシビリティツリーを書き出して確かめています。

パスワードの欄に「neko-Pass1」と入力すると黒丸で表示され、目のアイコンのボタンを1回クリックすると文字が見え、もう1回クリックすると黒丸に戻る
パスワードを入力して、目のアイコンのボタンを2回クリックする操作。下の type の表示は、変わった瞬間だけピンクになる(カーソル、クリックの輪、type と操作の回数の表示は GIF 用に重ねたもの)

type を state で切り替える部品

PasswordInput は、フォームで送るときの名前(name)、欄の見出し(label)、autoComplete の値を受け取ります。label と input は、React の useId で作った ID で結びつけています。

tsx
import { useId, useState } from 'react';

type Props = {
  name: string;
  label: string;
  autoComplete: 'current-password' | 'new-password';
};

export function PasswordInput({ name, label, autoComplete }: Props) {
  const id = useId();
  const [visible, setVisible] = useState(false);

  return (
    <div className="password-field">
      <label htmlFor={id}>{label}</label>
      <div className="password-row">
        <input
          id={id}
          name={name}
          type={visible ? 'text' : 'password'}
          autoComplete={autoComplete}
          autoCapitalize="off"
          spellCheck={false}
        />
        <button
          type="button"
          aria-label="パスワードを表示"
          aria-pressed={visible}
          aria-controls={id}
          onClick={() => setVisible((v) => !v)}
        >
          {visible ? <EyeOffIcon /> : <EyeIcon />}
        </button>
      </div>
    </div>
  );
}

EyeIcon と EyeOffIcon は、目の形と、それに斜線を重ねた形を描くだけの SVG です。どちらにも aria-hidden="true" を付け、読み上げの対象から外しています(コードは省きます)。

input には value も onChange も渡していません。値はブラウザの input が持ったままで、React が変えるのは type だけです。検証環境で「neko-Pass1」と入力してからボタンを2回クリックすると、type は password、text、password と変わり、値はどの時点でも「neko-Pass1」のままでした。

autoComplete と、文字が見えているときの入力の補助

autoComplete には、ログインの欄なら current-password、登録やパスワードの変更で新しいパスワードを入れる欄なら new-password を渡します。MDN の input type="password" の解説によると、どちらも、ブラウザやパスワードマネージャーが自動で入力するための手がかりになります。type を text にしている間も、autocomplete の属性は current-password のまま残っていました。

autoCapitalize="off" と spellCheck={false} は、文字を見せている間に効かせるための指定です。MDN の autocapitalize の説明には、type="password" の欄では自動の大文字化は起きないとあります。type="text" にすると、この前提がなくなります。そこで、スマートフォンのソフトウェアキーボードで先頭が大文字に変わらないように、off を指定しています。spellcheck を false にすると、スペルチェックの対象から外れます。同じ MDN の説明には、スペルチェックのために入力した内容が外部に送られることがあるので、機密の情報が入る欄では false を検討するように、とも書かれています。どちらの指定も、この記事の環境では効き目を確かめていません。

ボタンには type="button" を書く

フォームの中の button は、type を書かないと送信のボタンになります(MDN の button の type 属性の説明)。比べるために、切り替えのボタンから type="button" を外した版を試しました。送信の処理では、送信の回数を数えています。

切り替えのボタン ボタンを2回クリックしたあとの送信の回数
type="button" あり 0
type なし 2

type を書かない版では、ボタンを押すたびに(見せるときも隠すときも)フォームが送信されました。ログインのフォームなら、パスワードを確かめようとしただけでログインの処理が走ります。

type="button" を付けた版でも、パスワードの欄で Enter キーを押すと、フォームは送信されました。文字を見せている間(type が text の間)に Enter キーを押しても同じで、送信された値はどちらも次のとおりでした。type が text でも、値は同じ name で送られます。

text
{"email":"neko@example.com","password":"neko-Pass1"}

読み上げのための名前は変えずに、aria-pressed で状態を伝える

ボタンの中身はアイコンだけなので、aria-label で「パスワードを表示」という名前を付けています。見せているかどうかは、名前ではなく aria-pressed で伝えます。Playwright でアクセシビリティツリーを書き出すと、ボタンは次のようになりました。

text
クリックする前:
- button "パスワードを表示"
1回クリックしたあと:
- button "パスワードを表示" [pressed]

名前はそのままで、押されている状態だけが変わります。MDN の aria-pressed の説明には、トグルボタンの名前を状態に合わせて変えないこと、名前を変えたい場合は aria-pressed を使わないことが書かれています。W3C の ARIA Authoring Practices Guide のボタンのパターンにも、名前を変えないことが大切で、名前を変えるなら aria-pressed は要らない、と書かれています。

もう1つの書き方として、ボタンに「パスワードを表示する」「パスワードを隠す」と文字で書き、押すたびに文字を入れ替える方法もあります。この場合は aria-pressed を付けません。試した版では、ボタンの名前が次のように変わりました。

text
クリックする前:
- button "パスワードを表示する"
1回クリックしたあと:
- button "パスワードを隠す"

どちらの書き方でも、ボタンを押すと何が起きるかは、名前から分かります。名前を「表示」「隠す」と入れ替えながら aria-pressed も付けるのは、上の2つの資料がどちらも避けるように求めている書き方です。

ボタンを押したあと、フォーカスはボタンに移る

検証環境の Chromium 153 では、この部品のボタンをマウスでクリックすると、フォーカスはパスワードの欄からボタンに移りました。キーボードでは、パスワードの欄から Tab キーでボタンに移り、Space キーでも Enter キーでも切り替わりました。

操作 type フォーカスのある場所
パスワードの欄で Tab password 切り替えのボタン
Space text 切り替えのボタン
Enter password 切り替えのボタン

パスワードの欄に戻って続きを入力するときは、戻り方によって結果が変わりました。ボタンをクリックしたあとに欄の中央(入力した文字より右)をクリックして戻ると、カーソルは末尾に置かれ、「X」を入力すると「neko-Pass1X」になりました。一方、Shift+Tab キーで欄に戻ると、入力した文字がすべて選ばれた状態になりました。そのまま「X」を入力すると、値は「X」だけになりました。

注意

ボタンで切り替えてから Shift+Tab キーで欄に戻り、そのまま入力すると、それまでに入れたパスワードが置き換わります(検証環境の Chromium 153 で、マウスでクリックして切り替えたあとに試した結果)。

フォーカスを欄に残すと、カーソルが先頭に戻る

マウスで押してもフォーカスを欄に残したい場合は、ボタンの onMouseDown で e.preventDefault() を呼ぶ書き方があります。この書き方も試しました。

tsx
<button
  type="button"
  aria-label="パスワードを表示"
  aria-pressed={visible}
  aria-controls={id}
  onMouseDown={(e) => e.preventDefault()}
  onClick={() => setVisible((v) => !v)}
>

クリックしてもフォーカスは欄に残りましたが、カーソルの位置が先頭に戻りました。「neko」のあと(4文字目のあと)にカーソルを置いてからボタンをクリックし、「X」を入力した結果です。

操作 カーソルの位置 値
4文字目のあとにカーソルを置く 4 neko-Pass1
ボタンをクリック 0 neko-Pass1
「X」を入力 1 Xneko-Pass1

続きを入力したつもりが、パスワードの先頭に文字が入ります。React を使わない HTML の input と button で同じ操作をしても、カーソルは同じように先頭に戻りました。このため、React ではなく、ブラウザの側の動きだと考えています。もとの版のようにフォーカスをボタンに移した場合は、欄にフォーカスがない間、カーソルの位置は4のまま変わりませんでした。この記事の PasswordInput では、フォーカスを欄に残す書き方は採っていません。

​Posted by 86