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 @shuip/rhf-password-field
pnpm dlx shadcn@latest add @shuip/rhf-password-field
bun x shadcn@latest add @shuip/rhf-password-field
'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 render={<InputGroupButton aria-label='Info' size='icon-xs' />}>
<InfoIcon />
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)}
</InputGroupAddon>
</InputGroup>
{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}
{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.
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 { PasswordField } from '@/components/ui/shuip/react-hook-form/password-field';
const form = useForm<MyForm>({ defaultValues: { password: '' } });
const lens = useLens({ control: form.control });
<form onSubmit={form.handleSubmit(onSubmit)}>
<PasswordField lens={lens.focus('password')} label='Password' />
</form>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 { 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 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>
);
}
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 { 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 onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<PasswordField
lens={lens.focus('password')}
label='Password'
description='Your password'
placeholder='Password'
/>
<SubmitButton>Check</SubmitButton>
</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 { 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 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>
);
}
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 { 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 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>
);
}
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 { 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 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>
);
}
Props
Prop
Type