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>);}
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
tooltipprop - 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
'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'><PasswordFieldlens={lens.focus('password')}label='Password'description='Choose a strong password'placeholder='Enter password'/><PasswordFieldlens={lens.focus('confirmPassword')}label='Confirm Password'description='Re-enter your password to confirm'placeholder='Confirm password'/><SubmitButton>Set Password</SubmitButton></form></Form>);}
Default
'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'><PasswordFieldlens={lens.focus('password')}label='Password'description='Your password'placeholder='Password'/><SubmitButton>Check</SubmitButton></form></Form>);}
Login
'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' /><PasswordFieldlens={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
'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'><PasswordFieldlens={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) => (<divkey={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
'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'><PasswordFieldlens={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