Textarea Field

Multi-line text input component with optional tooltip. Built on shadcn InputGroup for enhanced UI.

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
import { InfoIcon } from 'lucide-react';
import type * as React from 'react';
import type { FieldPath, FieldValues, UseFormRegisterReturn } from 'react-hook-form';
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from '@/components/ui/input-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface TextareaFieldProps<T extends FieldValues> extends React.ComponentProps<typeof InputGroupTextarea> {
register: UseFormRegisterReturn<FieldPath<T>>;
label?: string;
description?: string;
tooltip?: React.ReactNode;
}
export function TextareaField<T extends FieldValues>({
register,
label,
description,
tooltip,
...props
}: TextareaFieldProps<T>) {
return (
<FormField
{...register}
render={({ field, fieldState }) => {
return (
<FormItem data-invalid={fieldState.invalid}>
{label && <FormLabel>{label}</FormLabel>}
<FormControl>
<InputGroup>
<InputGroupTextarea {...field} aria-invalid={fieldState.invalid} {...props} />
{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>
</FormControl>
<FormMessage className='text-xs text-left' />
{description && <FormDescription className='text-xs'>{description}</FormDescription>}
</FormItem>
);
}}
/>
);
}
Loading...

Multi-line text inputs are essential for longer content like descriptions, comments, or messages. TextareaField wraps a textarea element with InputGroup to enable an optional tooltip button positioned at the bottom-right corner—useful for character limits or formatting guidelines without cluttering the label area.

The component accepts native textarea props like rows and maxLength directly. Common patterns include pairing maxLength with Zod validation for minimum length requirements or using the description prop to display character count guidance.

Built-in features

  • Bottom-right tooltip positioned via InputGroup align='block-end'
  • Native textarea props support (rows, maxLength, placeholder)
  • Zod validation: Native integration with react-hook-form and Zod schemas
  • Multi-line error display for longer validation messages

Usage patterns

const schema = z.object({
  feedback: z.string().max(500, 'Maximum 500 characters'),
})

const form = useForm({
  defaultValues: { feedback: '' },
  resolver: zodResolver(schema),
})

Basic textarea

<TextareaField
  register={form.register('bio')}
  label='Bio'
  description='Tell us about yourself'
  rows={4}
/>

With character limit

<TextareaField
  register={form.register('feedback')}
  label='Feedback'
  description='Share your thoughts (max 500 characters)'
  rows={6}
  maxLength={500}
  placeholder='Your feedback...'
/>

With tooltip

<TextareaField
  register={form.register('notes')}
  label='Notes'
  tooltip='Supports plain text only. Markdown coming soon.'
  rows={8}
/>

Examples

Character Count

Loading...
'use client';
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)`),
});
export default function RhfTextareaFieldCharacterCountExample() {
const form = useForm({
defaultValues: { tweet: '' },
resolver: zodResolver(zodSchema),
});
const tweet = form.watch('tweet');
const remaining = MAX_LENGTH - tweet.length;
const isNearLimit = remaining < 50;
const isOverLimit = remaining < 0;
async function onSubmit(values: z.infer<typeof zodSchema>) {
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
register={form.register('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 { 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'),
});
export default function RhfTextareaFieldExample() {
const form = useForm({
defaultValues: { bio: '', feedback: '' },
resolver: zodResolver(zodSchema),
});
async function onSubmit(values: z.infer<typeof zodSchema>) {
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
register={form.register('bio')}
label='Biography'
description='Tell us about yourself'
rows={4}
placeholder='Software engineer passionate about...'
/>
<TextareaField
register={form.register('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 { 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'),
});
export default function RhfTextareaFieldTooltipExample() {
const form = useForm({
defaultValues: { notes: '' },
resolver: zodResolver(zodSchema),
});
async function onSubmit(values: z.infer<typeof zodSchema>) {
try {
alert(`Notes: ${values.notes}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<TextareaField
register={form.register('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