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 TextareaFieldPropsextends 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>);}
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
tooltipprop, positioned via InputGroupalign='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
'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'><TextareaFieldlens={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><spanclassName={`font-medium ${isOverLimit ? 'text-destructive' : isNearLimit ? 'text-orange-500' : 'text-muted-foreground'}`}>{remaining} remaining</span></div></div><SubmitButton>Post Tweet</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 { 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'><TextareaFieldlens={lens.focus('bio')}label='Biography'description='Tell us about yourself'rows={4}placeholder='Software engineer passionate about...'/><TextareaFieldlens={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
'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'><TextareaFieldlens={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