Skip to content
goodtabs UI
BrowseBrowse components

Password input

A password field with a show and hide toggle and an optional strength meter.

npx shadcn@latest add https://ui.goodtabs.co/r/password-input.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/password-input

Hidden by default. The eye button reveals the value and reports aria-pressed.

With a strength meter

Good

At least 8 characters with a mix of case, a number and a symbol.

Four bars and a label that update as you type. Monochrome, no colour.

In a sign in form

Under an email field, with a forgot password link on the label row.

Usage

import { PasswordInput } from "@/components/ui/password-input"

<PasswordInput id="password" autoComplete="current-password" />
<PasswordInput showStrength autoComplete="new-password" />

Props

PropTypeDefaultDescription
showStrengthbooleanfalseShow a four-bar strength meter and a Weak to Strong label under the field.
value, defaultValue, onChangeinput propsControlled or uncontrolled, exactly as on a plain input.

Source

password-input/password-input.tsx
"use client";

import * as React from "react";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

type PasswordInputProps = Omit<React.ComponentProps<typeof Input>, "type"> & {
  /** Show a four-bar strength meter and a label under the field. */
  showStrength?: boolean;
};

const STRENGTH_LABELS = ["Weak", "Weak", "Fair", "Good", "Strong"] as const;

/** A simple score from 0 to 4: length, mixed case, a number, a symbol. */
export function passwordStrength(value: string): number {
  if (!value) return 0;
  let score = 0;
  if (value.length >= 8) score += 1;
  if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score += 1;
  if (/\d/.test(value)) score += 1;
  if (/[^a-zA-Z0-9]/.test(value)) score += 1;
  return score;
}

/**
 * A password field with a show and hide toggle, and an optional strength meter.
 * Works controlled or uncontrolled like a plain input.
 */
export function PasswordInput({
  showStrength = false,
  value,
  defaultValue,
  onChange,
  className,
  disabled,
  ...props
}: PasswordInputProps) {
  const [visible, setVisible] = React.useState(false);
  const [inner, setInner] = React.useState(defaultValue === undefined ? "" : String(defaultValue));
  const current = value === undefined ? inner : String(value);
  const meterId = React.useId();

  const score = passwordStrength(current);
  const label = current ? STRENGTH_LABELS[score] : "";

  return (
    <div data-slot="password-input" className={cn("flex w-full flex-col gap-2", className)}>
      <div className="relative flex items-center">
        <Input
          {...props}
          type={visible ? "text" : "password"}
          value={value}
          defaultValue={defaultValue}
          onChange={(e) => {
            if (value === undefined) setInner(e.target.value);
            onChange?.(e);
          }}
          disabled={disabled}
          aria-describedby={showStrength ? cn(meterId, props["aria-describedby"]) : props["aria-describedby"]}
          className="pr-9"
        />
        <button
          type="button"
          onClick={() => setVisible((v) => !v)}
          disabled={disabled}
          aria-label={visible ? "Hide password" : "Show password"}
          aria-pressed={visible}
          className="absolute right-1.5 inline-grid size-6 place-items-center rounded-sm text-muted-foreground transition-colors duration-100 ease-out outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
        >
          {visible ? (
            <EyeOffIcon className="size-4" aria-hidden="true" />
          ) : (
            <EyeIcon className="size-4" aria-hidden="true" />
          )}
        </button>
      </div>
      {showStrength && (
        <div id={meterId} className="flex items-center gap-2">
          <div className="flex flex-1 gap-1" aria-hidden="true">
            {[1, 2, 3, 4].map((step) => (
              <span
                key={step}
                data-filled={score >= step || undefined}
                className="h-1 flex-1 rounded-full bg-muted transition-colors duration-150 ease-out data-filled:bg-foreground"
              />
            ))}
          </div>
          <span
            role="status"
            aria-live="polite"
            className="min-w-12 text-right font-mono text-xs text-muted-foreground tabular-nums"
          >
            {label}
          </span>
        </div>
      )}
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/password-input/prompt.md, or the Copy AI prompt button above.

When to use

  • Sign in, sign up and change password forms
  • Any secret the person types and may want to check, such as an API key
  • Set showStrength on new password fields only

When not to use

  • One-time codes: use the pin input
  • As a real password policy; the score is a hint, enforce rules on the server
  • For values that must never be revealed on screen

Agent notes

  • Client component. It extends shadcn's Input, so id, autoComplete, disabled and every input prop pass through
  • className goes on the wrapper, so set widths there; the field is always full width inside it
  • The strength score is 0 to 4: 8 or more characters, mixed case, a number, a symbol. passwordStrength is exported if you need it elsewhere
  • Pair with a Label via id and htmlFor; the meter is linked with aria-describedby and the label is a polite live region