Skip to content
38

Password Input

Password field with a show/hide toggle and an optional pluggable strength meter.

Example

Installation

npx shadcn@latest add https://hirael.com/r/password-input.json

Usage

import { PasswordInput, PasswordInputField, PasswordInputStrength } from "@/components/password-input"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<PasswordInput />

+ native element props
PropTypeDefault
idstringnone
valuestringnone
defaultValuestring''
onValueChange((value: string) => void)none
disabledbooleannone
scorerPasswordScorerdefaultPasswordScorer
children*React.ReactNodenone

<PasswordInputField />

+ native element props
PropTypeDefault
toggleLabel{ show: string; hide: string; }{ show: 'Show password', hide: 'Hide password' }

<PasswordInputStrength />

+ native element props
PropTypeDefault
label

Accessible name of the strength meter.

string'Password strength'

Component source

'use client';

import * as React from 'react';
import { Eye, EyeOff } from 'lucide-react';

import { cn } from '@/lib/utils';
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from '@/registry/hirael/bases/radix/ui/input-group';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';

export interface PasswordStrength {
  score: number;
  label: string;
  hint?: string;
}

export type PasswordScorer = (value: string) => PasswordStrength;

const LABELS = ['weak', 'weak', 'fair', 'good', 'strong'];
const HINTS = [
  '8+ chars, mix character types',
  'Try a longer passphrase',
  'Add a number or symbol',
  'Nearly there, make it longer',
  'Strong',
];

export const defaultPasswordScorer: PasswordScorer = (value) => {
  const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(value)).length;
  const score = Number(value.length >= 8) + Number(value.length >= 12) + Number(classes >= 2) + Number(classes >= 3);

  return { score, label: LABELS[score], hint: HINTS[score] };
};

interface Ctx {
  id: string;
  value: string;
  setValue: (v: string) => void;
  visible: boolean;
  setVisible: (v: boolean) => void;
  disabled?: boolean;
  strength: PasswordStrength;
}

const PasswordContext = React.createContext<Ctx | null>(null);

const usePasswordContext = () => {
  const ctx = React.useContext(PasswordContext);
  if (!ctx) {
    throw new Error('PasswordInput compound parts must be used inside <PasswordInput>');
  }

  return ctx;
};

export interface PasswordInputProps extends React.ComponentProps<'div'> {
  id?: string;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  disabled?: boolean;
  scorer?: PasswordScorer;
  children: React.ReactNode;
}

const PasswordInput = ({
  id,
  value: valueProp,
  defaultValue = '',
  onValueChange,
  disabled,
  scorer = defaultPasswordScorer,
  className,
  children,
  ...props
}: PasswordInputProps) => {
  const reactId = React.useId();
  const fieldId = id ?? reactId;

  const [value, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: defaultValue,
    onChange: onValueChange,
  });

  const [visible, setVisible] = React.useState(false);

  const strength = React.useMemo(() => scorer(value), [scorer, value]);

  const ctx = React.useMemo<Ctx>(
    () => ({ id: fieldId, value, setValue, visible, setVisible, disabled, strength }),
    [fieldId, value, setValue, visible, disabled, strength],
  );

  return (
    <PasswordContext.Provider value={ctx}>
      <div data-slot="password-input" className={cn('flex flex-col gap-2', className)} {...props}>
        {children}
      </div>
    </PasswordContext.Provider>
  );
};

interface PasswordInputFieldProps extends Omit<
  React.ComponentProps<'input'>,
  'type' | 'value' | 'defaultValue' | 'onChange' | 'id'
> {
  toggleLabel?: { show: string; hide: string };
}

/**
 * Defaults to `autoComplete="current-password"`; pass
 * `autoComplete="new-password"` for signup / change-password forms.
 */
const PasswordInputField = ({
  toggleLabel = { show: 'Show password', hide: 'Hide password' },
  className,
  ...props
}: PasswordInputFieldProps) => {
  const ctx = usePasswordContext();

  return (
    <InputGroup data-slot="password-input-field" data-disabled={ctx.disabled} className={className}>
      <InputGroupInput
        id={ctx.id}
        type={ctx.visible ? 'text' : 'password'}
        value={ctx.value}
        onChange={(e) => ctx.setValue(e.target.value)}
        disabled={ctx.disabled}
        autoComplete="current-password"
        {...props}
      />
      <InputGroupAddon align="inline-end">
        <InputGroupButton
          type="button"
          size="icon-sm"
          aria-label={ctx.visible ? toggleLabel.hide : toggleLabel.show}
          aria-pressed={ctx.visible}
          disabled={ctx.disabled}
          onClick={() => ctx.setVisible(!ctx.visible)}
        >
          {ctx.visible ? <EyeOff /> : <Eye />}
        </InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
  );
};

const STRENGTH_COLORS = ['bg-destructive', 'bg-destructive', 'bg-warning', 'bg-primary', 'bg-success'];

interface PasswordInputStrengthProps extends React.ComponentProps<'div'> {
  /** Accessible name of the strength meter. */
  label?: string;
}

const PasswordInputStrength = ({ label = 'Password strength', className, ...props }: PasswordInputStrengthProps) => {
  const { value, strength } = usePasswordContext();
  const bar = STRENGTH_COLORS[strength.score] ?? STRENGTH_COLORS[0];

  return (
    <div data-slot="password-input-strength" className={cn('flex flex-col gap-1.5', className)} {...props}>
      <div
        role="meter"
        aria-label={label}
        aria-valuenow={strength.score}
        aria-valuemin={0}
        aria-valuemax={4}
        aria-valuetext={strength.label}
        className="grid grid-cols-4 gap-1"
      >
        {[1, 2, 3, 4].map((tier) => (
          <span
            key={tier}
            className={cn(
              'h-1 rounded-sm bg-border transition-colors duration-200 ease-out',
              strength.score >= tier && bar,
            )}
          />
        ))}
      </div>
      {value && (
        <div aria-live="polite" className="flex items-center justify-between gap-2">
          <span className="text-xs text-muted-foreground uppercase">{strength.label}</span>
          {strength.hint && <span className="text-[11px] text-muted-foreground">{strength.hint}</span>}
        </div>
      )}
    </div>
  );
};

export { PasswordInput, PasswordInputField, PasswordInputStrength };

Dependencies

shadcn registry

input-groupuse-controllable-state

npm

lucide-react