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/tsf-textarea-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-textarea-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-textarea-field.json
import type {
DeepKeys,
DeepValue,
FieldAsyncValidateOrFn,
FieldOptions,
FieldValidateOrFn,
FormAsyncValidateOrFn,
FormValidateOrFn,
ReactFormApi,
} from '@tanstack/react-form';
import { InfoIcon } from 'lucide-react';
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<
TFormData,
TName extends DeepKeys<TFormData>,
TData extends DeepValue<TFormData, TName> = DeepValue<TFormData, TName>,
> {
form: ReactFormApi<
TFormData,
undefined | FormValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
undefined | FormAsyncValidateOrFn<TFormData>,
any
>;
name: TName;
label?: string;
description?: string;
formProps?: Partial<
FieldOptions<
TFormData,
TName,
TData,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>,
undefined | FieldValidateOrFn<TFormData, TName, TData>,
undefined | FieldAsyncValidateOrFn<TFormData, TName, TData>
>
>;
fieldProps?: React.ComponentProps<typeof Field>;
props?: any;
// props?: React.ComponentProps<typeof InputGroupTextarea>;
tooltip?: React.ReactNode;
}
export function TextareaField<
TFormData,
TName extends DeepKeys<TFormData>,
TData extends DeepValue<TFormData, TName> = DeepValue<TFormData, TName>,
>({
form,
name,
label,
description,
formProps,
fieldProps,
props,
tooltip,
}: TextareaFieldProps<TFormData, TName, TData>) {
return (
<form.Field name={name} {...formProps}>
{(field) => {
const { isValid, errors } = field.state.meta;
return (
<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
{label && <FieldLabel>{label}</FieldLabel>}
<InputGroup>
<InputGroupTextarea
name={field.name}
value={field.state.value as string}
onChange={(e) => field.handleChange(e.target.value as TData)}
onBlur={field.handleBlur}
aria-invalid={!isValid}
{...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>
{!isValid && (
<FieldError className='text-xs text-left' errors={errors.map((error) => ({ message: error }))} />
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}}
</form.Field>
);
}
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 through the props parameter. Common patterns include pairing maxLength with a character counter (accessible via form.useStore) or implementing minimum length validation for meaningful input.

Built-in features

  • Bottom-right tooltip positioned via InputGroup align='block-end'
  • Native textarea props support (rows, maxLength, placeholder)
  • Character limit validation with min/max length validators
  • Multi-line error display for longer validation messages

Length validation

<TextareaField
    form={form}
    name='feedback'
    label='Feedback'
    description='Share your thoughts (10-500 characters)'
    props={{ rows: 6, maxLength: 500, placeholder: 'Your feedback...' }}
    formProps={{
      validators: {
        onChange: ({ value }) => {
          if (value.length < 10) return 'Please provide at least 10 characters'
          if (value.length > 500) return 'Maximum 500 characters allowed'
          return undefined
        }
      }
    }}
/>

Examples

Character Count

Loading...
'use client';
import { useForm, useStore } from '@tanstack/react-form';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
import { TextareaField } from '@/components/ui/shuip/tanstack-form/textarea-field';
const MAX_LENGTH = 280;
export default function TsfTextareaFieldCharacterCountExample() {
const form = useForm({
defaultValues: {
tweet: '',
},
onSubmit: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
alert(JSON.stringify(value, null, 2));
},
});
const tweet = useStore(form.store, (state) => state.values.tweet);
const remaining = MAX_LENGTH - tweet.length;
const isNearLimit = remaining < 50;
const isOverLimit = remaining < 0;
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4 w-full max-w-lg'
>
<div className='space-y-2'>
<TextareaField
form={form}
name='tweet'
label='Tweet'
props={{
rows: 4,
maxLength: MAX_LENGTH,
placeholder: "What's happening?",
}}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return 'Tweet cannot be empty';
if (value.length > MAX_LENGTH) return `Tweet is too long (max ${MAX_LENGTH} characters)`;
return undefined;
},
},
}}
/>
<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 form={form}>Post Tweet</SubmitButton>
</form>
);
}

Default

Loading...
'use client';
import { useForm } from '@tanstack/react-form';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
import { TextareaField } from '@/components/ui/shuip/tanstack-form/textarea-field';
export default function TsfTextareaFieldExample() {
const form = useForm({
defaultValues: {
bio: '',
feedback: '',
},
onSubmit: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
alert(JSON.stringify(value, null, 2));
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<TextareaField
form={form}
name='bio'
label='Biography'
description='Tell us about yourself'
props={{ rows: 4, placeholder: 'Software engineer passionate about...' }}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return 'Bio is required';
if (value.length < 20) return 'Bio must be at least 20 characters';
return undefined;
},
},
}}
/>
<TextareaField
form={form}
name='feedback'
label='Feedback'
description='Share your thoughts or suggestions'
props={{ rows: 6, placeholder: 'Your feedback helps us improve...' }}
/>
<SubmitButton form={form}>Submit</SubmitButton>
</form>
);
}

Markdown Preview

Loading...
'use client';
import { useForm, useStore } from '@tanstack/react-form';
import { Card } from '@/components/ui/card';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
import { TextareaField } from '@/components/ui/shuip/tanstack-form/textarea-field';
export default function TsfTextareaFieldMarkdownPreviewExample() {
const form = useForm({
defaultValues: {
content: '',
},
onSubmit: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
alert(JSON.stringify(value, null, 2));
},
});
const content = useStore(form.store, (state) => state.values.content);
const htmlContent = parseMarkdown(content);
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<div className='flex max-md:flex-col items-center gap-4 w-full'>
<div className='mt-4'>
<TextareaField
form={form}
name='content'
label='Content'
description='Supports basic Markdown formatting'
tooltip={
<div className='space-y-1 text-sm'>
<p className='font-semibold'>Markdown syntax:</p>
<p># Heading 1</p>
<p>## Heading 2</p>
<p>**bold** *italic*</p>
<p>`code`</p>
<p>- List item</p>
</div>
}
props={{
rows: 12,
placeholder: '# My Article\n\nWrite your content here...',
className: 'min-h-[300px]',
}}
formProps={{
validators: {
onChange: ({ value }) => {
if (!value) return 'Content is required';
if (value.length < 10) return 'Content must be at least 10 characters';
return undefined;
},
},
}}
/>
</div>
<Card className='p-4 min-h-[300px]'>
{content ? (
// biome-ignore lint/security/noDangerouslySetInnerHtml: demo
<div className='prose prose-sm max-w-none' dangerouslySetInnerHTML={{ __html: htmlContent }} />
) : (
<p className='text-muted-foreground'>Nothing to preview yet. Write some content to see the preview.</p>
)}
</Card>
</div>
<SubmitButton form={form}>Publish Article</SubmitButton>
</form>
);
}
function parseMarkdown(text: string): string {
return text
.split('\n')
.map((line) => {
if (line.startsWith('### ')) return `<h3 class="text-lg font-semibold mb-2">${line.slice(4)}</h3>`;
if (line.startsWith('## ')) return `<h2 class="text-xl font-semibold mb-2">${line.slice(3)}</h2>`;
if (line.startsWith('# ')) return `<h1 class="text-2xl font-bold mb-2">${line.slice(2)}</h1>`;
if (line.startsWith('- ')) return `<li class="ml-4">${line.slice(2)}</li>`;
line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
line = line.replace(/\*(.*?)\*/g, '<em>$1</em>');
line = line.replace(/`(.*?)`/g, '<code class="bg-muted px-1 rounded">$1</code>');
return line ? `<p class="mb-2">${line}</p>` : '<br>';
})
.join('\n');
}

Props

Prop

Type

On this page