Skip to contentVibraUI
Inputs & filters

Password input

A password field with a reveal toggle and an optional four-segment strength meter.

Controlled when value is set, uncontrolled otherwise; the meter reads whichever is in force. Only the wording sits in the polite live region, and it changes only when the score does, so a screen reader hears a step up rather than every keystroke. Every filled segment takes the tone of the score reached — all four green at Strong rather than a red-to-green gradient — so the bar reads as one verdict. The spoken form says what the word is about (Password strength: Weak), since the bar carrying that context is not announced. getPasswordStrength is exported so a form can score a password without rendering the field.

Install

npx shadcn@latest add @vibra/password-input

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
showStrengthbooleanfalseShows the four-segment meter and its label under the field.
size"sm" | "default""default"sm drops the field to h-7 for dense forms.
valuestring—The password; setting it makes the field controlled.
defaultValuestring—The starting password for an uncontrolled field.
onChangeReact.ChangeEventHandler<HTMLInputElement>—The input's own change handler; every other input prop passes through too.
getPasswordStrength(password: string) => { score: 0 | 1 | 2 | 3 | 4; label: string }—Scores a password: a point each for eight characters, twelve, mixed case, a digit, and a symbol, capped at four.

Dependencies

Source

components/ui/password-input.tsx
"use client"

import * as React from "react"
import { EyeIcon, EyeOffIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

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

// The whole meter takes the tone of the score it reached, so the bar reads as
// one verdict rather than a gradient.
const STRENGTH_TONES = [
  "bg-muted",
  "bg-danger",
  "bg-warning",
  "bg-warning",
  "bg-success",
] as const

export type PasswordStrength = {
  score: 0 | 1 | 2 | 3 | 4
  label: (typeof STRENGTH_LABELS)[number]
}

/** Scores a password out of four: a point each for eight characters, twelve characters, mixed case, a digit, and a symbol. */
export function getPasswordStrength(password: string): PasswordStrength {
  if (!password) return { score: 0, label: STRENGTH_LABELS[0] }

  const checks = [
    password.length >= 8,
    password.length >= 12,
    /[a-z]/.test(password) && /[A-Z]/.test(password),
    /\d/.test(password),
    /[^\w\s]|_/.test(password),
  ]
  const score = Math.min(4, checks.filter(Boolean).length) as PasswordStrength["score"]
  return { score, label: STRENGTH_LABELS[score] }
}

export type PasswordInputProps = Omit<React.ComponentProps<"input">, "type" | "size"> & {
  /** Shows a four-segment strength meter under the field. */
  showStrength?: boolean
  size?: "sm" | "default"
}

/** A password field with a reveal toggle and an optional strength meter. */
function PasswordInput({
  className,
  showStrength = false,
  size = "default",
  value,
  defaultValue,
  onChange,
  disabled,
  ...props
}: PasswordInputProps) {
  const isControlled = value !== undefined
  const [visible, setVisible] = React.useState(false)
  const [uncontrolled, setUncontrolled] = React.useState(String(defaultValue ?? ""))
  const current = isControlled ? String(value ?? "") : uncontrolled
  const { score, label } = getPasswordStrength(current)

  return (
    <div
      data-slot="password-input"
      data-size={size}
      data-visible={visible || undefined}
      className={cn("flex w-full flex-col gap-2", className)}
    >
      <div className="relative">
        <Input
          type={visible ? "text" : "password"}
          autoComplete="current-password"
          disabled={disabled}
          className={cn("pe-8", size === "sm" ? "h-7" : "h-8")}
          {...(isControlled ? { value } : { defaultValue })}
          onChange={(event) => {
            if (!isControlled) setUncontrolled(event.target.value)
            onChange?.(event)
          }}
          {...props}
        />
        <Button
          type="button"
          variant="ghost"
          size="icon-xs"
          disabled={disabled}
          aria-label={visible ? "Hide password" : "Show password"}
          aria-pressed={visible}
          onClick={() => setVisible((previous) => !previous)}
          className="absolute top-1/2 end-1 -translate-y-1/2 text-muted-foreground"
        >
          {visible ? <EyeOffIcon /> : <EyeIcon />}
        </Button>
      </div>

      {showStrength ? (
        <div
          data-slot="password-input-strength"
          data-score={score}
          className="flex flex-col gap-1.5"
        >
          <div aria-hidden="true" className="flex items-center gap-1">
            {[1, 2, 3, 4].map((segment) => (
              <span
                key={segment}
                className={cn(
                  "h-1 flex-1 rounded-full transition-colors",
                  segment <= score ? STRENGTH_TONES[score] : "bg-muted"
                )}
              />
            ))}
          </div>
          {/* Only the wording lives in the live region, and it only changes when
              the score does — so it is spoken on a step up, not on every key.
              The bar carries the context for anyone who can see it; the spoken
              form has to say what the one word is about. */}
          <span
            data-slot="password-input-strength-label"
            aria-live="polite"
            className="min-h-4 text-xs text-muted-foreground"
          >
            {current ? (
              <>
                <span className="sr-only">Password strength: </span>
                {label}
              </>
            ) : null}
          </span>
        </div>
      ) : null}
    </div>
  )
}

export { PasswordInput }