Skip to content
6

Password Input

Show/hide toggle with an optional pluggable strength meter. Compound and single-prop APIs.

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 />

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

<PasswordInputField />

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

<PasswordInputStrength />

+ native element props
PropTypeDefault
showLabelbooleantrue
renderMeta((strength: PasswordStrength) => React.ReactNode)none

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';

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

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

export const defaultPasswordScorer: PasswordScorer = (value) => {
  if (!value) return { score: 0, label: 'empty' };
  let score = 0;
  if (value.length >= 8) score++;
  if (value.length >= 12) score++;
  const classes =
    Number(/[a-z]/.test(value)) +
    Number(/[A-Z]/.test(value)) +
    Number(/\d/.test(value)) +
    Number(/[^A-Za-z0-9]/.test(value));
  if (classes >= 2) score++;
  if (classes >= 3) score++;
  score = Math.min(score, 4);
  const labels = ['weak', 'weak', 'fair', 'good', 'strong'] as const;
  const hints = [
    '8+ chars, mix character types',
    'Try a longer passphrase',
    'Add a number or symbol',
    'Nearly there, make it longer',
    'Strong',
  ] as const;
  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 {
  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,
  children,
}: PasswordInputProps) => {
  const reactId = React.useId();
  const fieldId = id ?? reactId;

  const [internalValue, setInternalValue] = React.useState(defaultValue);
  const value = valueProp ?? internalValue;
  const setValue = React.useCallback(
    (next: string) => {
      if (valueProp === undefined) setInternalValue(next);
      onValueChange?.(next);
    },
    [valueProp, 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}>{children}</PasswordContext.Provider>;
};

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

/**
 * Defaults to `autoComplete="current-password"`; pass
 * `autoComplete="new-password"` for signup / change-password forms.
 */
const PasswordInputField = ({
  showToggle = true,
  toggleLabel = { show: 'Show password', hide: 'Hide password' },
  className,
  ...props
}: PasswordInputFieldProps) => {
  const ctx = usePasswordContext();
  return (
    <InputGroup
      data-slot="password-input-field"
      data-disabled={ctx.disabled || undefined}
      className={cn(ctx.disabled && 'opacity-60', 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}
      />
      {showToggle && (
        <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'] as const;

interface PasswordInputStrengthProps extends React.ComponentProps<'div'> {
  showLabel?: boolean;
  renderMeta?: (strength: PasswordStrength) => React.ReactNode;
}

const PasswordInputStrength = ({ showLabel = true, renderMeta, className, ...props }: PasswordInputStrengthProps) => {
  const ctx = usePasswordContext();
  const s = ctx.strength;
  const bar = STRENGTH_COLORS[s.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="Password strength"
        aria-valuenow={s.score}
        aria-valuemin={0}
        aria-valuemax={4}
        aria-valuetext={s.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', s.score >= tier && bar)}
          />
        ))}
      </div>
      {showLabel &&
        (renderMeta ? (
          renderMeta(s)
        ) : (
          <div aria-live="polite" className="flex items-center justify-between gap-2">
            <span className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">{s.label}</span>
            {s.hint && <span className="text-[11px] text-muted-foreground">{s.hint}</span>}
          </div>
        ))}
    </div>
  );
};

export { PasswordInput, PasswordInputField, PasswordInputStrength };

Dependencies

shadcn registry

input-group

npm

lucide-react