Password Field

Password input component with visibility toggle and optional tooltip. Built on shadcn InputGroup for enhanced UI.

npx shadcn@latest add @shuip/tsf-password-field
pnpm dlx shadcn@latest add @shuip/tsf-password-field
bun x shadcn@latest add @shuip/tsf-password-field
'use client';

import { Eye, EyeOff, InfoIcon } from 'lucide-react';
import React from 'react';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';

export interface PasswordFieldProps {
  label?: string;
  description?: string;
  fieldProps?: React.ComponentProps<typeof Field>;
  props?: React.ComponentProps<'input'>;
  tooltip?: React.ReactNode;
}

export function PasswordField({ label, description, fieldProps, props, tooltip }: PasswordFieldProps) {
  const field = useFieldContext<string>();
  const { isValid, errors } = field.state.meta;
  const [showPassword, setShowPassword] = React.useState(false);

  const handleTogglePassword = React.useCallback(() => {
    setShowPassword((prev) => !prev);
  }, []);

  return (
    <Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
      {label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}
      <InputGroup>
        <InputGroupInput
          id={field.name}
          type={showPassword ? 'text' : 'password'}
          placeholder='Enter password'
          name={field.name}
          value={field.state.value}
          onChange={(e) => field.handleChange(e.target.value)}
          onBlur={field.handleBlur}
          aria-invalid={!isValid}
          {...props}
        />
        <InputGroupAddon align='inline-end'>
          <InputGroupButton aria-label='Toggle password' onClick={handleTogglePassword}>
            {showPassword ? <EyeOff className='size-4' /> : <Eye className='size-4' />}
          </InputGroupButton>
          {tooltip && (
            <Tooltip>
              <TooltipTrigger render={<InputGroupButton aria-label='Info' size='icon-xs' />}>
                <InfoIcon />
              </TooltipTrigger>
              <TooltipContent>{tooltip}</TooltipContent>
            </Tooltip>
          )}
        </InputGroupAddon>
      </InputGroup>
      {!isValid && (
        <FieldError
          className='text-xs text-left'
          errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}
        />
      )}
      {description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
    </Field>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

Loading...

Password inputs require special UX considerations: users need to verify their input while maintaining security. PasswordField solves this with a built-in visibility toggle (Eye/EyeOff icons) that switches between masked and plain text.

The component reads the surrounding field via useFieldContext, so you compose it inside a <form.AppField>. It uses shadcn's InputGroup to position the toggle button inline with the input, avoiding layout shifts. An optional tooltip can be added via the tooltip prop to display password requirements — this appears as an InfoIcon button next to the visibility toggle.

Built-in features

  • Context-bound field state via useFieldContext — no prop drilling
  • One-click visibility toggle with Eye/EyeOff icons
  • InputGroup positioning for seamless button integration
  • Optional tooltip for password requirements or help text
  • Linked field validation via onChangeListenTo for confirmation fields

Setup

Field components are bound via React context. In your project, create lib/form.ts once:

// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { PasswordField } from '@/components/ui/shuip/tanstack-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

export const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { PasswordField },
  formComponents: { SubmitButton },
});

See the form-context item for details.

Password security patterns

Common validation pattern for strong passwords:

import { useAppForm } from '@/lib/form';

const form = useAppForm({
  defaultValues: { password: '', confirmPassword: '' },
  onSubmit: async ({ value }) => {
    await saveData(value);
  },
});

<form.AppField
  name='password'
  validators={{
    onChange: ({ value }) => {
      if (value.length < 8) return 'At least 8 characters required';
      if (!/[A-Z]/.test(value)) return 'Must include uppercase letter';
      if (!/[0-9]/.test(value)) return 'Must include number';
      if (!/[!@#$%^&*]/.test(value)) return 'Must include special character';
      return undefined;
    },
  }}
  children={(field) => (
    <field.PasswordField
      label='Password'
      tooltip='Must contain: 8+ characters, uppercase, number, special char'
    />
  )}
/>

Confirm password with linked validation:

<form.AppField
  name='confirmPassword'
  validators={{
    onChangeListenTo: ['password'],
    onChange: ({ value, fieldApi }) => {
      const password = fieldApi.form.getFieldValue('password');
      return value !== password ? 'Passwords do not match' : undefined;
    },
  }}
  children={(field) => (
    <field.PasswordField label='Confirm Password' />
  )}
/>

Examples

Confirm Password

Loading...
'use client';

import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { PasswordField } from '@/components/ui/shuip/tanstack-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { PasswordField },
  formComponents: { SubmitButton },
});

export default function TsfPasswordFieldExample() {
  const form = useAppForm({
    defaultValues: {
      password: '',
      confirmPassword: '',
    },
    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      alert(JSON.stringify(value, null, 2));
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='password'
        validators={{
          onChange: ({ value }) => (value.length < 8 ? 'Password must be at least 8 characters' : undefined),
        }}
        children={(field) => <field.PasswordField label='Password' />}
      />

      <form.AppField
        name='confirmPassword'
        validators={{
          onChangeListenTo: ['password'],
          onChange: ({ value, fieldApi }) => {
            const password = fieldApi.form.getFieldValue('password');
            if (value !== password) return 'Passwords do not match';
            return undefined;
          },
        }}
        children={(field) => <field.PasswordField label='Confirm Password' />}
      />

      <form.AppForm>
        <form.SubmitButton />
      </form.AppForm>
    </form>
  );
}

Default

Loading...
'use client';

import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { PasswordField } from '@/components/ui/shuip/tanstack-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { PasswordField },
  formComponents: { SubmitButton },
});

export default function TsfPasswordFieldExample() {
  const form = useAppForm({
    defaultValues: {
      password: '',
    },

    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      alert(JSON.stringify(value, null, 2));
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='password'
        validators={{
          onChange: ({ value }) => (value.length < 8 ? 'Password must be at least 8 characters' : undefined),
        }}
        children={(field) => <field.PasswordField label='Password' />}
      />

      <form.AppForm>
        <form.SubmitButton />
      </form.AppForm>
    </form>
  );
}

Strength Meter

Loading...
'use client';

import { createFormHook, useStore } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { PasswordField } from '@/components/ui/shuip/tanstack-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
import { cn } from '@/lib/utils';

// Calculate password strength score (0-4)
function calculatePasswordStrength(password: string): number {
  let strength = 0;

  if (password.length >= 8) strength++;
  if (password.length >= 12) strength++;
  if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
  if (/[0-9]/.test(password)) strength++;
  if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength++;

  return Math.min(strength, 4);
}

function getStrengthLabel(strength: number): { label: string; color: string } {
  const labels = [
    { label: 'Very Weak', color: 'bg-red-500' },
    { label: 'Weak', color: 'bg-orange-500' },
    { label: 'Fair', color: 'bg-yellow-500' },
    { label: 'Good', color: 'bg-lime-500' },
    { label: 'Strong', color: 'bg-green-500' },
  ];
  return labels[strength] || labels[0];
}

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { PasswordField },
  formComponents: { SubmitButton },
});

export default function TsfPasswordFieldStrengthMeterExample() {
  const form = useAppForm({
    defaultValues: {
      password: '',
      confirmPassword: '',
    },
    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      alert(JSON.stringify(value, null, 2));
    },
  });

  // Subscribe to password value changes
  const password = useStore(form.store, (state) => state.values.password);
  const strength = password ? calculatePasswordStrength(password) : 0;
  const { label, color } = getStrengthLabel(strength);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <div className='space-y-2'>
        <form.AppField
          name='password'
          validators={{
            onChange: ({ value }) => {
              if (!value) return 'Password is required';
              if (value.length < 8) return 'Password must be at least 8 characters';
              if (!/[A-Z]/.test(value)) return 'Must include uppercase letter';
              if (!/[a-z]/.test(value)) return 'Must include lowercase letter';
              if (!/[0-9]/.test(value)) return 'Must include number';
              if (!/[!@#$%^&*(),.?":{}|<>]/.test(value)) return 'Must include special character';
              return undefined;
            },
          }}
          children={(field) => (
            <field.PasswordField
              label='Password'
              tooltip={
                <div className='space-y-1'>
                  <p className='font-semibold'>Password must contain:</p>
                  <ul className='list-disc list-inside text-sm space-y-0.5'>
                    <li>At least 8 characters (12+ recommended)</li>
                    <li>Uppercase and lowercase letters</li>
                    <li>At least one number</li>
                    <li>At least one special character</li>
                  </ul>
                </div>
              }
              props={{ placeholder: 'Enter password' }}
            />
          )}
        />

        {/* Strength meter */}
        {password && (
          <div className='space-y-1.5'>
            <div className='flex gap-1'>
              {[...Array(4)].map((_, i) => (
                <div
                  key={i}
                  className={cn('h-1 flex-1 rounded-full transition-colors', i < strength ? color : 'bg-muted')}
                />
              ))}
            </div>
            <p className='text-sm text-muted-foreground'>
              Strength: <span className='font-medium'>{label}</span>
            </p>
          </div>
        )}
      </div>

      <form.AppField
        name='confirmPassword'
        validators={{
          onChangeListenTo: ['password'],
          onChange: ({ value, fieldApi }) => {
            const pwd = fieldApi.form.getFieldValue('password');
            if (!value) return 'Please confirm your password';
            if (value !== pwd) return 'Passwords do not match';
            return undefined;
          },
        }}
        children={(field) => (
          <field.PasswordField label='Confirm Password' props={{ placeholder: 'Re-enter password' }} />
        )}
      />

      <form.AppForm>
        <form.SubmitButton>Create Account</form.SubmitButton>
      </form.AppForm>
    </form>
  );
}

Tooltip

Loading...
'use client';

import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { PasswordField } from '@/components/ui/shuip/tanstack-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { PasswordField },
  formComponents: { SubmitButton },
});

export default function TsfPasswordFieldExample() {
  const form = useAppForm({
    defaultValues: {
      password: '',
    },

    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      alert(JSON.stringify(value, null, 2));
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='password'
        validators={{
          onChange: ({ value }) => (value.length < 8 ? 'Password must be at least 8 characters' : undefined),
        }}
        children={(field) => (
          <field.PasswordField label='Password' tooltip='Use a strong password with letters, numbers, and symbols' />
        )}
      />

      <form.AppForm>
        <form.SubmitButton />
      </form.AppForm>
    </form>
  );
}

Props

Prop

Type

On this page