Textarea Field

Multi-line text input component integrated with React Hook Form via typed lens binding from @hookform/lenses. Supports tooltips and InputGroup integration for character limits or formatting guidelines.

npx shadcn@latest add https://shuip.plvo.dev/r/rhf-textarea-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-textarea-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-textarea-field.json
'use client';
import type { Lens } from '@hookform/lenses';
import { InfoIcon } from 'lucide-react';
import type * as React from 'react';
import { useController } from 'react-hook-form';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from '@/components/ui/input-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface TextareaFieldProps
extends Omit<React.ComponentProps<typeof InputGroupTextarea>, 'value' | 'onChange'> {
lens: Lens<string>;
label?: string;
description?: string;
tooltip?: React.ReactNode;
}
export function TextareaField({ lens, label, description, tooltip, ...props }: TextareaFieldProps) {
const { field, fieldState } = useController(lens.interop());
const id = props.id ?? field.name;
return (
<Field className='gap-2' data-invalid={fieldState.invalid}>
{label && <FieldLabel htmlFor={id}>{label}</FieldLabel>}
<InputGroup>
<InputGroupTextarea {...field} {...props} id={id} value={field.value ?? ''} aria-invalid={fieldState.invalid} />
{tooltip && (
<InputGroupAddon align='block-end' className='justify-end'>
<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' errors={[fieldState.error]} />}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
Loading...

TextareaField is a multi-line text 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 a textarea element: wiring event handlers, displaying errors, managing touched states, and rendering consistent UI.

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('bio') autocompletes from your form's value type — no <MyForm> generic at the call site
  • Bottom-right tooltip: optional InfoIcon button with tooltip content via tooltip prop, positioned via InputGroup align='block-end'
  • Native textarea props: full support for rows, maxLength, placeholder, and other native attributes
  • 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 { TextareaField } from '@/components/ui/shuip/react-hook-form/textarea-field';

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

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <TextareaField lens={lens.focus('bio')} label='Bio' />
  </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:

<FormField
  control={form.control}
  name="bio"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Bio</FormLabel>
      <FormControl>
        <Textarea placeholder="Tell us about yourself..." rows={4} {...field} />
      </FormControl>
      <FormDescription>
        Tell us about yourself
      </FormDescription>
      <FormMessage />
    </FormItem>
  )}
/>

With TextareaField, this reduces to a single declarative component:

<TextareaField
  lens={lens.focus('bio')}
  label="Bio"
  description="Tell us about yourself"
  placeholder="Tell us about yourself..."
  rows={4}
/>

Examples

Character Count

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 { TextareaField } from '@/components/ui/shuip/react-hook-form/textarea-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const MAX_LENGTH = 280;
const zodSchema = z.object({
tweet: z.string().min(1, 'Tweet cannot be empty').max(MAX_LENGTH, `Tweet is too long (max ${MAX_LENGTH} characters)`),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfTextareaFieldCharacterCountExample() {
const form = useForm<Values>({
defaultValues: { tweet: '' },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
const tweet = form.watch('tweet') ?? '';
const remaining = MAX_LENGTH - tweet.length;
const isNearLimit = remaining < 50;
const isOverLimit = remaining < 0;
async function onSubmit(values: Values) {
try {
alert(`Tweet: ${values.tweet}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4 w-full max-w-lg'>
<div className='space-y-2'>
<TextareaField
lens={lens.focus('tweet')}
label='Tweet'
rows={4}
maxLength={MAX_LENGTH}
placeholder="What's happening?"
/>
<div className='flex justify-between text-sm'>
<span className='text-muted-foreground'>
{tweet.length} / {MAX_LENGTH} characters
</span>
<span
className={`font-medium ${
isOverLimit ? 'text-destructive' : isNearLimit ? 'text-orange-500' : 'text-muted-foreground'
}`}
>
{remaining} remaining
</span>
</div>
</div>
<SubmitButton>Post Tweet</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 { TextareaField } from '@/components/ui/shuip/react-hook-form/textarea-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
bio: z.string().min(20, 'Bio must be at least 20 characters'),
feedback: z.string().max(500, 'Maximum 500 characters'),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfTextareaFieldExample() {
const form = useForm<Values>({
defaultValues: { bio: '', feedback: '' },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
try {
alert(`Bio: ${values.bio}\nFeedback: ${values.feedback}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<TextareaField
lens={lens.focus('bio')}
label='Biography'
description='Tell us about yourself'
rows={4}
placeholder='Software engineer passionate about...'
/>
<TextareaField
lens={lens.focus('feedback')}
label='Feedback'
description='Share your thoughts (max 500 characters)'
rows={6}
maxLength={500}
placeholder='Your feedback...'
/>
<SubmitButton>Submit</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 { TextareaField } from '@/components/ui/shuip/react-hook-form/textarea-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
notes: z.string().min(10, 'Notes must be at least 10 characters'),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfTextareaFieldTooltipExample() {
const form = useForm<Values>({
defaultValues: { notes: '' },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
try {
alert(`Notes: ${values.notes}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<TextareaField
lens={lens.focus('notes')}
label='Notes'
description='Add any additional notes or comments'
tooltip={
<div className='space-y-1 text-sm'>
<p className='font-semibold'>Formatting tips:</p>
<ul className='list-disc list-inside space-y-0.5'>
<li>Keep it concise and clear</li>
<li>Use bullet points for lists</li>
<li>Mention important details first</li>
</ul>
</div>
}
rows={8}
placeholder='Enter your notes here...'
/>
<SubmitButton>Save Notes</SubmitButton>
</form>
</Form>
);
}

Props

Prop

Type

On this page