Password input
A password field with a show and hide toggle.
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
autoComplete so managers offer to fill and save.Rules before mistakes
Anatomy#
- Group. An Input group at 36px (
h-9).classNamelands here, not on the input. - Input.
type="password"until revealed. Spell check and auto-capitalization are off. - Visibility toggle. A 24px ghost icon button with
aria-pressed, named Show password. The eye and eye-off icons cross-fade in place. - 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".
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#
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> );}| State | Treatment |
|---|---|
| Masked | The default. The eye icon is shown. |
| Revealed | Plain text. The toggle is pressed and shows the eye-off icon. It stays revealed until pressed again. |
| Focus visible | The group's indigo border and 3px ring at 50%. The toggle has its own ring. |
| Invalid | aria-invalid on the input gives the group a red border and a 3px red ring at 20%. |
| Disabled | disabled dims the group to 50%, for example while an account is locked after too many attempts. The toggle stays clickable. |
Behavior#
- Every prop except
typeandclassNamegoes to the inner input:id,name,value,onChange,autoComplete,aria-*,ref. classNamegoes to the Input group wrapper, so use it for height and width, such ash-8in 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
Webhook signing secret
whsec_9f2c41d7b0e84a6cContent#
- 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
autoCompletetocurrent-passwordornew-password, andname="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-describedbyso 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.
| Keys | Action |
|---|---|
| Tab | Moves from the input to the visibility toggle. |
| Space | Shows or hides the password when the toggle has focus. |
| Enter | Submits the form from the input. |
Design tokens#
| Token | Used for |
|---|---|
--input | Group stroke |
--ring | Focus border and ring |
--destructive | Invalid border and ring |
--muted-foreground | Toggle icon at rest; Graphite Ink on hover |
--muted | Toggle hover fill |
API reference#
PasswordInput
A password input with a show and hide toggle.
Other props spread onto <input>, without type.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | "h-9" | Applied to the Input group wrapper, not the input. |
autoComplete | string | No default | "current-password" to sign in, "new-password" to create one. |
aria-invalid | boolean | No default | Turns the group red. |
disabled | boolean | No default | Dims 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.