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.

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 { 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

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 { 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

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 { 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

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 { 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

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 { 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

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 { 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

On this page