Password Field

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

npx shadcn@latest add https://shuip.plvo.dev/r/tsf-password-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-password-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-password-field.json
'use client';
import type {
DeepKeys,
DeepValue,
FieldAsyncValidateOrFn,
FieldOptions,
FieldValidateOrFn,
FormAsyncValidateOrFn,
FormValidateOrFn,
ReactFormApi,
} from '@tanstack/react-form';
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface PasswordFieldProps<
TFormData,
TName extends DeepKeys<TFormData>,
TData extends DeepValue<TFormData, TName>,
> {
form: ReactFormApi<
TFormData,
undefined | FormValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
any
>;
name: TName;
label?: string;
description?: string;
formProps?: Partial<
FieldOptions<
TFormData,
TName,
TData,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>
>
>;
fieldProps?: React.ComponentProps<typeof Field>;
props?: React.ComponentProps<'input'>;
tooltip?: React.ReactNode;
}
export function PasswordField<TFormData, TName extends DeepKeys<TFormData>, TData extends DeepValue<TFormData, TName>>({
form,
name,
label,
description,
formProps,
fieldProps,
props,
tooltip,
}: PasswordFieldProps<TFormData, TName, TData>) {
const [showPassword, setShowPassword] = React.useState(false);
const handleTogglePassword = React.useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
return (
<form.Field name={name} {...formProps}>
{(field) => {
const { isValid, errors } = field.state.meta;
return (
<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
{label && <FieldLabel>{label}</FieldLabel>}
<InputGroup>
<InputGroupInput
type={showPassword ? 'text' : 'password'}
placeholder='Enter password'
name={field.name}
value={field.state.value as string}
onChange={(e) => field.handleChange(e.target.value as TData)}
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 asChild>
<InputGroupButton aria-label='Info' size='icon-xs'>
<InfoIcon />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)}
</InputGroupAddon>
</InputGroup>
{!isValid && (
<FieldError className='text-xs text-left' errors={errors.map((error) => ({ message: error }))} />
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}}
</form.Field>
);
}
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 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

  • 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

Password security patterns

Common validation pattern for strong passwords:

<PasswordField
    form={form}
    name='password'
    label='Password'
    tooltip='Must contain: 8+ characters, uppercase, number, special char'
    formProps={{
      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
        }
      }
    }}
/>

Confirm password with linked validation:

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

Examples

Confirm Password

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { PasswordField } from '@/components/ui/shuip/tanstack-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfPasswordFieldExample() {
const form = useForm({
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'
>
<PasswordField
form={form}
name='password'
label='Password'
formProps={{
validators: {
onChange: ({ value }) => (value.length < 8 ? 'Password must be at least 8 characters' : undefined),
},
}}
/>
<PasswordField
form={form}
name='confirmPassword'
label='Confirm Password'
formProps={{
validators: {
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) => {
const password = fieldApi.form.getFieldValue('password');
if (value !== password) return 'Passwords do not match';
return undefined;
},
},
}}
/>
<SubmitButton form={form} />
</form>
);
}

Default

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { PasswordField } from '@/components/ui/shuip/tanstack-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfPasswordFieldExample() {
const form = useForm({
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'
>
<PasswordField
form={form}
name='password'
label='Password'
formProps={{
validators: {
onChange: ({ value }) => (value.length < 8 ? 'Password must be at least 8 characters' : undefined),
},
}}
/>
<SubmitButton form={form} />
</form>
);
}

Strength Meter

Loading...
'use client';
import { useForm, useStore } from '@tanstack/react-form';
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];
}
export default function TsfPasswordFieldStrengthMeterExample() {
const form = useForm({
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'>
<PasswordField
form={form}
name='password'
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' }}
formProps={{
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;
},
},
}}
/>
{/* 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>
<PasswordField
form={form}
name='confirmPassword'
label='Confirm Password'
props={{ placeholder: 'Re-enter password' }}
formProps={{
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;
},
},
}}
/>
<SubmitButton form={form}>Create Account</SubmitButton>
</form>
);
}

Tooltip

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { PasswordField } from '@/components/ui/shuip/tanstack-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export default function TsfPasswordFieldExample() {
const form = useForm({
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'
>
<PasswordField
form={form}
name='password'
label='Password'
formProps={{
validators: {
onChange: ({ value }) => (value.length < 8 ? 'Password must be at least 8 characters' : undefined),
},
}}
tooltip='Use a strong password with letters, numbers, and symbols'
/>
<SubmitButton form={form} />
</form>
);
}

Props

Prop

Type

On this page