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><InputGroupTextareaname={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 (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4 w-full max-w-lg'><div className='space-y-2'><TextareaFieldform={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><spanclassName={`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 (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><TextareaFieldform={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;},},}}/><TextareaFieldform={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 (<formonSubmit={(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'><TextareaFieldform={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