Skip to content

Password input

A password field with a show and hide toggle.

Status
Beta
Category
Inputs
Adoption
Not used yet
import { PasswordInput } from "@oration/canon/components/password-input";
packages/canon/src/components/password-input.tsx

Sign in to Cedarline

Use your work email.

import { Field, FieldError, FieldGroup, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { PasswordInput } from "@oration/canon/components/password-input";import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import Link from "next/link";import * as React from "react";export function Hero() {    const id = React.useId();    const [password, setPassword] = React.useState("");    const [error, setError] = React.useState<string>();    const [pending, setPending] = React.useState(false);    return (        <form            noValidate            className="flex w-full max-w-sm flex-col gap-6 rounded-xl bg-card p-6 text-left shadow-border"            onSubmit={(event) => {                event.preventDefault();                if (!password) {                    setError("Enter your password.");                    document.getElementById(`${id}-password`)?.focus();                    return;                }                setPending(true);                window.setTimeout(() => {                    setPending(false);                    toast.add({                        type: "success",                        title: "Signed in as Maya Okafor",                    });                }, 900);            }}        >            <div className="flex flex-col gap-1.5">                <p className="text-xl font-semibold tracking-[-0.015em] text-foreground">                    Sign in to Cedarline                </p>                <p className="text-sm text-muted-foreground">                    Use your work email.                </p>            </div>            <FieldGroup className="gap-4">                <Field>                    <FieldLabel htmlFor={`${id}-email`}>Email</FieldLabel>                    <Input                        id={`${id}-email`}                        type="email"                        name="email"                        autoComplete="username"                        defaultValue="maya.okafor@cedarline.io"                        className="h-9"                    />                </Field>                <Field                    data-invalid={error ? true : undefined}                    className="relative"                >                    <FieldLabel htmlFor={`${id}-password`}>Password</FieldLabel>                    <PasswordInput                        id={`${id}-password`}                        name="password"                        autoComplete="current-password"                        value={password}                        onChange={(event) => {                            setPassword(event.target.value);                            if (error) setError(undefined);                        }}                        aria-invalid={error ? true : undefined}                        aria-describedby={                            error ? `${id}-password-error` : undefined                        }                    />                    <FieldError id={`${id}-password-error`}>{error}</FieldError>                    <Link                        href="/design/templates/auth"                        className="absolute top-0 right-0 w-auto! text-13 leading-snug text-muted-foreground transition-colors duration-150 hover:text-foreground"                    >                        Forgot password?                    </Link>                </Field>            </FieldGroup>            <PendingButton                type="submit"                size="lg"                pending={pending}                className="w-full"            >                Sign in            </PendingButton>        </form>    );}

Usage#

Password input is a password field with a trailing eye button that shows and hides what was typed. It is built on Input group and sized for the sign-in and sign-up screens, where fields are 36px. The eye is a toggle: it keeps the name Show password and reports its state with aria-pressed. It is for secrets a person types and remembers, not for API keys and tokens that people copy; those belong in a copy row or a key-value editor with secrets.

When to use

  • For the password on sign-in, with autoComplete="current-password".
  • For a new password on sign-up or reset, with autoComplete="new-password" and the rules shown up front.
  • For re-entering a password to confirm a sensitive change, such as transferring workspace ownership.

When not to use

  • For an API key, signing secret or token that people copy rather than type. Use Copy row
  • For header values or environment variables that may be secret. Use Key-value editor
  • For a one-time code from email or SMS. Use One-time code
  • For a masked field with more than one inline action. Use Input group

Never block paste

People paste passwords from password managers. Don't prevent paste, don't disable autofill, and set the right autoComplete so managers offer to fill and save.

Rules before mistakes

Show the requirements for a new password before people type, and tick them off as they are met, instead of rejecting the password after submit.

Anatomy#

  1. Group. An Input group at 36px (h-9). className lands here, not on the input.
  2. Input. type="password" until revealed. Spell check and auto-capitalization are off.
  3. Visibility toggle. A 24px ghost icon button with aria-pressed, named Show password. The eye and eye-off icons cross-fade in place.
  4. Tooltip. Show password or Hide password, after the 400ms tooltip delay.

Examples#

New password with rules

List the rules under the field, tick them off as they are met, and check the confirmation on submit. Both fields use autoComplete="new-password".

  • At least 12 characters, not met yet
  • One number or symbol, not met yet
  • Not your email address, met
import { Button } from "@oration/canon/components/button";import { Field, FieldError, FieldLabel } from "@oration/canon/components/field";import { PasswordInput } from "@oration/canon/components/password-input";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, CircleIcon } from "lucide-react";import * as React from "react";export function NewPassword() {    const id = React.useId();    const [password, setPassword] = React.useState("cedarline");    const [confirm, setConfirm] = React.useState("");    const [error, setError] = React.useState<string>();    const rules = [        { label: "At least 12 characters", met: password.length >= 12 },        { label: "One number or symbol", met: /[\d\W_]/.test(password) },        {            label: "Not your email address",            met: !password.includes("maya.okafor"),        },    ];    return (        <form            noValidate            className="flex w-full max-w-sm flex-col gap-4"            onSubmit={(event) => {                event.preventDefault();                if (rules.some((rule) => !rule.met)) {                    setError(undefined);                    document.getElementById(`${id}-new`)?.focus();                    toast.add({                        type: "error",                        title: "The new password doesn't meet every rule yet",                    });                    return;                }                if (confirm !== password) {                    setError(                        "The passwords don't match. Type the new password again.",                    );                    document.getElementById(`${id}-confirm`)?.focus();                    return;                }                setError(undefined);                toast.add({                    type: "success",                    title: "Password changed",                    description: "Other sessions were signed out.",                });            }}        >            <Field>                <FieldLabel htmlFor={`${id}-new`}>New password</FieldLabel>                <PasswordInput                    id={`${id}-new`}                    autoComplete="new-password"                    value={password}                    onChange={(event) => setPassword(event.target.value)}                    aria-describedby={`${id}-rules`}                    className="h-8"                />                <ul id={`${id}-rules`} className="flex flex-col gap-1 text-13">                    {rules.map((rule) => (                        <li                            key={rule.label}                            className={cn(                                "flex items-center gap-1.5 transition-colors duration-150",                                rule.met                                    ? "text-foreground"                                    : "text-muted-foreground",                            )}                        >                            {rule.met ? (                                <CheckIcon                                    aria-hidden="true"                                    className="size-3.5 text-success"                                />                            ) : (                                <CircleIcon                                    aria-hidden="true"                                    className="size-3.5 text-subtle-foreground"                                />                            )}                            {rule.label}                            <span className="sr-only">                                {rule.met ? ", met" : ", not met yet"}                            </span>                        </li>                    ))}                </ul>            </Field>            <Field data-invalid={error ? true : undefined}>                <FieldLabel htmlFor={`${id}-confirm`}>                    Confirm new password                </FieldLabel>                <PasswordInput                    id={`${id}-confirm`}                    autoComplete="new-password"                    value={confirm}                    onChange={(event) => {                        setConfirm(event.target.value);                        if (error) setError(undefined);                    }}                    aria-invalid={error ? true : undefined}                    aria-describedby={error ? `${id}-confirm-error` : undefined}                    className="h-8"                />                <FieldError id={`${id}-confirm-error`}>{error}</FieldError>            </Field>            <Button type="submit" className="self-start">                Change password            </Button>        </form>    );}

Sizes

36px by default for auth screens. Pass className="h-8" to match the 32px controls in settings and dialogs; the class lands on the group.

Default, 36px, for sign-in and sign-up.

32px through className, in settings and dialogs.

import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { PasswordInput } from "@oration/canon/components/password-input";import * as React from "react";export function Sizes() {    const id = React.useId();    return (        <div className="grid w-full max-w-xl gap-6 sm:grid-cols-2">            <Field>                <FieldLabel htmlFor={`${id}-auth`}>Password</FieldLabel>                <PasswordInput                    id={`${id}-auth`}                    autoComplete="current-password"                    defaultValue="orchard-street-42"                    aria-describedby={`${id}-auth-description`}                />                <FieldDescription                    id={`${id}-auth-description`}                    className="text-[13px]"                >                    Default, 36px, for sign-in and sign-up.                </FieldDescription>            </Field>            <Field>                <FieldLabel htmlFor={`${id}-settings`}>                    Current password                </FieldLabel>                <PasswordInput                    id={`${id}-settings`}                    autoComplete="current-password"                    defaultValue="orchard-street-42"                    className="h-8"                    aria-describedby={`${id}-settings-description`}                />                <FieldDescription                    id={`${id}-settings-description`}                    className="text-[13px]"                >                    32px through className, in settings and dialogs.                </FieldDescription>            </Field>        </div>    );}

States#

Rest
Focus
Invalid
Disabled
import { PasswordInput } from "@oration/canon/components/password-input";import { cn } from "@oration/canon/lib/utils";export function StatesRow() {    const states = [        { name: "Rest", group: "", invalid: false, disabled: false },        {            name: "Focus",            group: "border-ring ring-3 ring-ring/50",            invalid: false,            disabled: false,        },        { name: "Invalid", group: "", invalid: true, disabled: false },        { name: "Disabled", group: "", invalid: false, disabled: true },    ];    return (        <div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">            {states.map((state) => (                <div key={state.name} className="flex min-w-0 flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <PasswordInput                        aria-label={`Password, ${state.name}`}                        defaultValue="halcyon-remit"                        tabIndex={-1}                        aria-invalid={state.invalid || undefined}                        disabled={state.disabled}                        className={cn("pointer-events-none h-8", state.group)}                    />                </div>            ))}        </div>    );}
States
StateTreatment
MaskedThe default. The eye icon is shown.
RevealedPlain text. The toggle is pressed and shows the eye-off icon. It stays revealed until pressed again.
Focus visibleThe group's indigo border and 3px ring at 50%. The toggle has its own ring.
Invalidaria-invalid on the input gives the group a red border and a 3px red ring at 20%.
Disableddisabled dims the group to 50%, for example while an account is locked after too many attempts. The toggle stays clickable.

Behavior#

  • Every prop except type and className goes to the inner input: id, name, value, onChange, autoComplete, aria-*, ref.
  • className goes to the Input group wrapper, so use it for height and width, such as h-8 in a settings dialog.
  • Visibility is internal state. It starts masked on mount and there is no prop to control it or mask again after submit.
  • The toggle is a type="button", so it never submits the form, and it doesn't move focus from the button.
  • The icons cross-fade on a 300ms spring with a slight blur. Under reduced motion only the fade runs.

Do and don't#

At least 12 characters

Do. Show the rules for a new password under the field and tick them off as they are met.
Don't. Reveal the rules only in an error after submit, so people guess and fail first.

Webhook signing secret

whsec_9f2c41d7b0e84a6c
Do. Use it for passwords people type. Use a copy row for keys people copy.
Don't. Put an API signing secret in a password field, where it can be edited and can't be copied in one click.

Content#

  • The label is Password, or New password and Confirm new password when both appear.
  • State requirements as positives: At least 12 characters, One number or symbol.
  • Errors give the fix: Enter your password. or Use at least 12 characters. Don't say whether the email or the password was wrong after a failed sign-in; say That email and password don't match.
  • Pair the field with a Forgot password? link at the label's right edge on sign-in.

Accessibility#

  • Label the input with a <label htmlFor>; the toggle's name doesn't name the field.
  • Set autoComplete to current-password or new-password, and name="password", so managers fill and save correctly.
  • The toggle keeps one name, Show password, and reports pressed and not pressed. That is the correct toggle pattern; its tooltip switches wording.
  • Link rules and errors with aria-describedby so they are read with the field.
  • The toggle is 24px, the minimum target; auth screens are touch-friendly because the field around it is 36px.
Keyboard interactions
KeysAction
TabMoves from the input to the visibility toggle.
SpaceShows or hides the password when the toggle has focus.
EnterSubmits the form from the input.

Design tokens#

Design tokens
TokenUsed for
--inputGroup stroke
--ringFocus border and ring
--destructiveInvalid border and ring
--muted-foregroundToggle icon at rest; Graphite Ink on hover
--mutedToggle hover fill

API reference#

PasswordInput

A password input with a show and hide toggle.

Other props spread onto <input>, without type.

Props of PasswordInput
PropTypeDefaultDescription
classNamestring"h-9"Applied to the Input group wrapper, not the input.
autoCompletestringNo default"current-password" to sign in, "new-password" to create one.
aria-invalidbooleanNo defaultTurns the group red.
disabledbooleanNo defaultDims the group and blocks input.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

It is 36px by default, taller than the 32px control height. That matches the auth screens; in settings and dialogs pass className="h-8".

The tooltip says Hide password while revealed but the accessible name stays Show password, so sighted and screen reader users get different words.

Only the auth screens import it. Settings has its own copy (apps/web/src/components/settings/account-security/password-input.tsx) with no tooltip and a name that changes with state.

There is no visible or onVisibleChange prop, so a form can't mask the field again after a failed submit.