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 { 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 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: typeof error === 'string' ? error : error?.message }))}
/>
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</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 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