Password Field

Password input component integrated with React Hook Form via typed lens binding from @hookform/lenses. Includes visibility toggle, optional tooltip, and InputGroup integration.

npx shadcn@latest add https://shuip.plvo.dev/r/rhf-password-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-password-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-password-field.json
'use client';
import type { Lens } from '@hookform/lenses';
import { Eye, EyeOff, InfoIcon } from 'lucide-react';
import * as React from 'react';
import { useController } from 'react-hook-form';
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 extends Omit<React.ComponentProps<typeof InputGroupInput>, 'value' | 'onChange'> {
lens: Lens<string>;
label?: string;
description?: string;
tooltip?: React.ReactNode;
}
export function PasswordField({ lens, label, description, tooltip, ...props }: PasswordFieldProps) {
const { field, fieldState } = useController(lens.interop());
const [showPassword, setShowPassword] = React.useState(false);
const id = props.id ?? field.name;
const handleTogglePassword = React.useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
return (
<Field className='gap-2' data-invalid={fieldState.invalid}>
{label && <FieldLabel htmlFor={id}>{label}</FieldLabel>}
<InputGroup>
<InputGroupInput
{...field}
placeholder='Enter password'
{...props}
id={id}
type={showPassword ? 'text' : 'password'}
value={field.value ?? ''}
aria-invalid={fieldState.invalid}
/>
<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>
{fieldState.invalid && <FieldError className='text-xs text-left opacity-80' errors={[fieldState.error]} />}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
Loading...

PasswordField is a password input component that encapsulates React Hook Form's field management with shadcn/ui's design system. It handles all the boilerplate of connecting form state to an input element: wiring event handlers, displaying errors, managing touched states, and rendering consistent UI — with a built-in visibility toggle on top.

The field binds to the form via a typed lens from @hookform/lenses — no call-site generic, just lens.focus('fieldName') with full autocomplete from your form's value type.

Built-in features

  • Typed lens binding: lens.focus('password') autocompletes from your form's value type — no <MyForm> generic at the call site
  • One-click visibility toggle: Eye/EyeOff icons switch between masked and plain text
  • Tooltip integration: optional InfoIcon button with tooltip content via tooltip prop
  • InputGroup ready: built on shadcn InputGroup for seamless addon integration
  • Zod validation: native integration with react-hook-form and Zod via resolver

Setup

Field components bind via @hookform/lenses. Create a lens once per form:

import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { Form } from '@/components/ui/form';
import { PasswordField } from '@/components/ui/shuip/react-hook-form/password-field';

const form = useForm<MyForm>({ defaultValues: { password: '' } });
const lens = useLens({ control: form.control });

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <PasswordField lens={lens.focus('password')} label='Password' />
  </form>
</Form>

The <Form> wrapper is required — it provides shadcn's FormProvider which FormLabel and FormMessage use internally.

Less boilerplate

React Hook Form's standard approach uses render props to access field state, plus extra state to wire the visibility toggle:

const [showPassword, setShowPassword] = useState(false);

<FormField
  control={form.control}
  name="password"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Password</FormLabel>
      <FormControl>
        <InputGroup>
          <InputGroupInput type={showPassword ? 'text' : 'password'} {...field} />
          <InputGroupAddon align='inline-end'>
            <InputGroupButton onClick={() => setShowPassword((v) => !v)}>
              {showPassword ? <EyeOff /> : <Eye />}
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>

With PasswordField, this reduces to a single declarative component:

<PasswordField
  lens={lens.focus('password')}
  label="Password"
  tooltip='Must contain: 8+ characters, uppercase, number, special char'
/>

Examples

Confirm Password

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { PasswordField } from '@/components/ui/shuip/react-hook-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z
.object({
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type Values = z.infer<typeof zodSchema>;
export default function RhfPasswordFieldConfirmPasswordExample() {
const form = useForm<Values>({
defaultValues: {
password: '',
confirmPassword: '',
},
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(_values: Values) {
try {
alert(`Password set successfully!`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<PasswordField
lens={lens.focus('password')}
label='Password'
description='Choose a strong password'
placeholder='Enter password'
/>
<PasswordField
lens={lens.focus('confirmPassword')}
label='Confirm Password'
description='Re-enter your password to confirm'
placeholder='Confirm password'
/>
<SubmitButton>Set Password</SubmitButton>
</form>
</Form>
);
}

Default

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { PasswordField } from '@/components/ui/shuip/react-hook-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
password: z
.string()
.nonempty({ message: 'Password is required' })
.min(8, { message: 'Password must be at least 8 characters' })
.regex(/[A-Z]/, { message: 'Password must contain at least one uppercase letter' })
.regex(/[0-9]/, { message: 'Password must contain at least one number' })
.regex(/[!@#$%^&*]/, { message: 'Password must contain at least one special character' }),
});
type Values = z.infer<typeof zodSchema>;
export default function PasswordFieldExample() {
const form = useForm<Values>({
defaultValues: { password: '' },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
try {
alert(`Hello ${values.password}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<PasswordField
lens={lens.focus('password')}
label='Password'
description='Your password'
placeholder='Password'
/>
<SubmitButton>Check</SubmitButton>
</form>
</Form>
);
}

Login

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Form } from '@/components/ui/form';
import { InputField } from '@/components/ui/shuip/react-hook-form/input-field';
import { PasswordField } from '@/components/ui/shuip/react-hook-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
email: z.email({ message: 'Invalid email' }),
password: z.string().nonempty({ message: 'Password is required' }),
});
type Values = z.infer<typeof zodSchema>;
export default function PasswordFieldLoginExample() {
const form = useForm<Values>({
defaultValues: { email: '', password: '' },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
try {
alert(`Hello ${values.email} ${values.password}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>Login</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<InputField lens={lens.focus('email')} label='Email' placeholder='john@example.com' />
<PasswordField
lens={lens.focus('password')}
label='Password'
placeholder='Password'
tooltip='Must contain: 8+ characters, uppercase, number, special char'
/>
</CardContent>
<CardFooter>
<SubmitButton>Login</SubmitButton>
</CardFooter>
</Card>
</form>
</Form>
);
}

Strength Meter

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { PasswordField } from '@/components/ui/shuip/react-hook-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
import { cn } from '@/lib/utils';
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 zodSchema = z
.object({
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must include uppercase letter')
.regex(/[a-z]/, 'Must include lowercase letter')
.regex(/[0-9]/, 'Must include number')
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'Must include special character'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type Values = z.infer<typeof zodSchema>;
export default function RhfPasswordFieldStrengthMeterExample() {
const form = useForm<Values>({
defaultValues: {
password: '',
confirmPassword: '',
},
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
const password = form.watch('password');
const strength = password ? calculatePasswordStrength(password) : 0;
const { label, color } = getStrengthLabel(strength);
async function onSubmit(_values: Values) {
try {
alert(`Account created with password strength: ${label}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<div className='space-y-2'>
<PasswordField
lens={lens.focus('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>
}
placeholder='Enter password'
/>
{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 lens={lens.focus('confirmPassword')} label='Confirm Password' placeholder='Re-enter password' />
<SubmitButton>Create Account</SubmitButton>
</form>
</Form>
);
}

Tooltip

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { PasswordField } from '@/components/ui/shuip/react-hook-form/password-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
password: z
.string()
.nonempty({ message: 'Password is required' })
.min(8, { message: 'Password must be at least 8 characters' })
.regex(/[A-Z]/, { message: 'Password must contain at least one uppercase letter' })
.regex(/[0-9]/, { message: 'Password must contain at least one number' })
.regex(/[!@#$%^&*]/, { message: 'Password must contain at least one special character' }),
});
type Values = z.infer<typeof zodSchema>;
export default function PasswordFieldTooltipExample() {
const form = useForm<Values>({
defaultValues: { password: '' },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
try {
alert(`Hello ${values.password}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<PasswordField
lens={lens.focus('password')}
label='Password'
description='Your password'
placeholder='Password'
tooltip='Must contain: 8+ characters, uppercase, number, special char'
/>
<SubmitButton>Check</SubmitButton>
</form>
</Form>
);
}

Props

Prop

Type

On this page