v1.0

InputPassword

Preview

Basic

Loading…

Preview

Code

ts
import InputPassword from '@/components/form/InputPassword';

src/components/form/InputPassword.tsx

AI prompt

text
Build a password input component with a show/hide toggle, a strength meter and an optional requirements checklist in React + TypeScript + Tailwind CSS.

## Look
- Input: standard text input with `pr-8`. Toggle button inside at the right (`absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300`, focus ring indigo-400) showing a 14px `Eye` (hidden) or `EyeOff` (revealed).
- Meter (`feedback`), `mt-1.5 flex items-center gap-2`: three `h-1 flex-1 rounded-full` segments with `gap-1`, unlit `bg-slate-200 dark:bg-slate-700`; lit count and colour by level — weak 1 `bg-rose-500 dark:bg-rose-400`, medium 2 `bg-amber-500 dark:bg-amber-400`, strong 3 `bg-emerald-500 dark:bg-emerald-400`, with `transition-colors`. Then the word, `w-14 text-right text-[11px] font-semibold`, in the matching 600 / dark 400 text colour ("Weak", "Medium", "Strong"), or "Strength" in slate-400 when empty.
- Requirements checklist, `mt-1.5 space-y-0.5`: rows `flex items-center gap-1.5 text-[11px]` with a 12px `Check` (met, `text-emerald-600 dark:text-emerald-400`) or `X` (unmet, slate-500 / dark slate-400) and the rule label.

## Behaviour
- Strength (export `passwordStrength(pw, requiredLength = 8)`): empty → none; shorter than requiredLength → weak regardless of variety; otherwise one point per character class present (lower, upper, digit, symbol), +1 at length ≥ requiredLength + 4, +1 at ≥ 2 × requiredLength; ≥4 strong, 3 medium, else weak. It's a nudge, not a security control.
- Default rules when `requirements` is `true`: "At least 8 characters", "A number", "An uppercase letter", "A symbol"; or pass your own `{ label, test(pw) }[]`.
- Works controlled or uncontrolled: when `value` is undefined, mirror the input into local state from its onChange so the meter still updates.
- `requiredLength` is NOT passed as `minLength`, so it never blocks submit on its own.
- Forward the ref and native props to the input; `className` on the input, `wrapperClassName` on the wrapper.

## API
`feedback = false`, `requirements: boolean | PasswordRule[] = false`, `requiredLength = 8`, `wrapperClassName?`, plus native input props except `type`.

## Accessibility
- Toggle: a FIXED `aria-label="Show password"` with `aria-pressed` (a flipping label would announce "Hide password, pressed"), `aria-controls` the input, and a `title` that does flip.
- Meter and checklist are linked to the input via `aria-describedby` (merged with any the caller passes). Only the level word is `aria-live="polite"`; each rule carries a visually-hidden "(met)"/"(not met)" (keep the list `relative` so those sr-only spans stay anchored).

## Demo
A toggle-only field prefilled "hunter2", and a "Choose a password" field with feedback and requirements, echoing its value.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { forwardRef, useId, useState } from 'react';
import { Check, Eye, EyeOff, X } from 'lucide-react';
import { cn } from '@/lib/cn';

export type PasswordStrength = 'none' | 'weak' | 'medium' | 'strong';

export type PasswordRule = {
  label: string;
  test: (password: string) => boolean;
};

/**
 * A rough strength estimate from length and character classes.
 *
 * Deliberately simple and exported, so a server can mirror it — it is a nudge
 * toward a better password, not a security control. Anything shorter than
 * `requiredLength` is weak regardless of variety: "Ab1!" uses every class and
 * is still four characters.
 */
export function passwordStrength(password: string, requiredLength = 8): PasswordStrength {
  if (!password) return 'none';
  if (password.length < requiredLength) return 'weak';
  const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
  // One point per character class, plus length bonuses — so a long lower-case
  // passphrase can reach "medium" without being forced to sprout a "!".
  let score = classes;
  if (password.length >= requiredLength + 4) score++;
  if (password.length >= requiredLength * 2) score++;
  return score >= 4 ? 'strong' : score === 3 ? 'medium' : 'weak';
}

const LEVEL = {
  none: { bars: 0, label: 'Strength', tone: 'bg-slate-200 dark:bg-slate-700', text: 'text-slate-400 dark:text-slate-500' },
  weak: { bars: 1, label: 'Weak', tone: 'bg-rose-500 dark:bg-rose-400', text: 'text-rose-600 dark:text-rose-400' },
  medium: { bars: 2, label: 'Medium', tone: 'bg-amber-500 dark:bg-amber-400', text: 'text-amber-600 dark:text-amber-400' },
  strong: { bars: 3, label: 'Strong', tone: 'bg-emerald-500 dark:bg-emerald-400', text: 'text-emerald-600 dark:text-emerald-400' },
} as const;

const defaultRules = (min: number): PasswordRule[] => [
  { label: `At least ${min} characters`, test: (p) => p.length >= min },
  { label: 'A number', test: (p) => /\d/.test(p) },
  { label: 'An uppercase letter', test: (p) => /[A-Z]/.test(p) },
  { label: 'A symbol', test: (p) => /[^A-Za-z0-9]/.test(p) },
];

export interface InputPasswordProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
  /** Show the three-segment strength bar and its label under the input. */
  feedback?: boolean;
  /** `true` for the default checklist (length, number, uppercase, symbol), or your own rules. */
  requirements?: boolean | PasswordRule[];
  /**
   * The length the meter and the checklist ask for. Not passed to the input as
   * `minLength`, so it never blocks a submit on its own — add that yourself.
   */
  requiredLength?: number;
  /** Classes for the wrapper. `className` goes on the `<input>`. */
  wrapperClassName?: string;
}

/**
 * A password input with a reveal toggle and optional strength feedback.
 *
 * Works controlled or uncontrolled: the meter needs the current value either
 * way, so an uncontrolled input is mirrored into local state from its own
 * `onChange`. The meter and checklist are wired to the input through
 * `aria-describedby`, and only the level's WORD is a live region — announcing
 * the whole checklist on every keystroke would drown out the typing.
 */
const InputPassword = forwardRef<HTMLInputElement, InputPasswordProps>(function InputPassword(
  {
    feedback = false,
    requirements = false,
    requiredLength = 8,
    wrapperClassName,
    className,
    value,
    defaultValue,
    onChange,
    disabled,
    id,
    'aria-describedby': describedBy,
    ...rest
  },
  ref,
) {
  const uid = useId();
  const inputId = id ?? `${uid}-input`;
  const [visible, setVisible] = useState(false);
  const [inner, setInner] = useState(String(defaultValue ?? ''));
  const current = value !== undefined ? String(value) : inner;

  const level = LEVEL[passwordStrength(current, requiredLength)];
  const rules = requirements === true ? defaultRules(requiredLength) : requirements || [];
  const meterId = `${uid}-meter`;
  const rulesId = `${uid}-rules`;
  const describedByAll =
    [describedBy, feedback && meterId, rules.length > 0 && rulesId].filter(Boolean).join(' ') || undefined;

  return (
    <div className={cn('w-full', wrapperClassName)}>
      <div className="relative">
        <input
          ref={ref}
          id={inputId}
          {...rest}
          type={visible ? 'text' : 'password'}
          value={value}
          defaultValue={value === undefined ? defaultValue : undefined}
          disabled={disabled}
          aria-describedby={describedByAll}
          onChange={(e) => {
            if (value === undefined) setInner(e.target.value);
            onChange?.(e);
          }}
          className={cn('field-input pr-8', className)}
        />
        <button
          type="button"
          onClick={() => setVisible((v) => !v)}
          disabled={disabled}
          // A fixed name plus `aria-pressed`, not a label that flips to "Hide":
          // changing both would announce "Hide password, pressed" — a double negative.
          aria-label="Show password"
          aria-controls={inputId}
          aria-pressed={visible}
          title={visible ? 'Hide password' : 'Show password'}
          className={cn(
            'absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1',
            'text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300',
            'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 disabled:cursor-not-allowed',
          )}
        >
          {visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
        </button>
      </div>

      {feedback && (
        <div id={meterId} className="mt-1.5 flex items-center gap-2">
          <div className="flex flex-1 gap-1" aria-hidden>
            {[0, 1, 2].map((i) => (
              <span
                key={i}
                className={cn(
                  'h-1 flex-1 rounded-full transition-colors',
                  i < level.bars ? level.tone : 'bg-slate-200 dark:bg-slate-700',
                )}
              />
            ))}
          </div>
          <span className={cn('w-14 text-right text-[11px] font-semibold', level.text)} aria-live="polite">
            {level.label}
          </span>
        </div>
      )}

      {rules.length > 0 && (
        <ul id={rulesId} className="relative mt-1.5 space-y-0.5">
          {/* `relative` anchors each rule's `sr-only` status: it is
              `position: absolute`, and unanchored it escapes a scrolling
              container and lengthens the document. */}
          {rules.map((rule) => {
            const met = rule.test(current);
            return (
              <li
                key={rule.label}
                className={cn(
                  'flex items-center gap-1.5 text-[11px]',
                  met ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-500 dark:text-slate-400',
                )}
              >
                {met ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
                <span>{rule.label}</span>
                <span className="sr-only">{met ? '(met)' : '(not met)'}</span>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
});

export default InputPassword;

Props

PropTypeDefaultDescription
feedbackbooleanfalseShow the three-segment strength bar and its label under the input.
requirementsboolean | PasswordRule[]false`true` for the default checklist (length, number, uppercase, symbol), or your own rules.
requiredLengthnumber8The length the meter and the checklist ask for. Not passed to the input as `minLength`, so it never blocks a submit on its own — add that yourself.
wrapperClassNamestring—Classes for the wrapper. `className` goes on the `<input>`.

Also accepts every prop <input> takes — they are spread onto the root element.