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
'use client';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 { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';export interface TextareaFieldProps {label?: string;description?: string;fieldProps?: React.ComponentProps<typeof Field>;props?: React.ComponentProps<typeof InputGroupTextarea>;tooltip?: React.ReactNode;}export function TextareaField({ label, description, fieldProps, props, tooltip }: TextareaFieldProps) {const field = useFieldContext<string>();const { isValid, errors } = field.state.meta;return (<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}<InputGroup><InputGroupTextareaid={field.name}name={field.name}value={field.state.value}onChange={(e) => field.handleChange(e.target.value)}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 && (<FieldErrorclassName='text-xs text-left'errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}/>)}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
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 reads the surrounding field via useFieldContext, so you compose it inside a <form.AppField>. It 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
- Context-bound field state via
useFieldContext— no prop drilling - 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
Setup
Field components are bound via React context. In your project, create lib/form.ts once:
// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { TextareaField } from '@/components/ui/shuip/tanstack-form/textarea-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { TextareaField },
formComponents: { SubmitButton },
});See the form-context item for details.
Length validation
import { useAppForm } from '@/lib/form';
const form = useAppForm({
defaultValues: { feedback: '' },
onSubmit: async ({ value }) => {
await saveData(value);
},
});
<form.AppField
name='feedback'
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;
},
}}
children={(field) => (
<field.TextareaField
label='Feedback'
description='Share your thoughts (10-500 characters)'
props={{ rows: 6, maxLength: 500, placeholder: 'Your feedback...' }}
/>
)}
/>Examples
Character Count
'use client';import { createFormHook, useStore } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';import { TextareaField } from '@/components/ui/shuip/tanstack-form/textarea-field';const MAX_LENGTH = 280;const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { TextareaField },formComponents: { SubmitButton },});export default function TsfTextareaFieldCharacterCountExample() {const form = useAppForm({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'><form.AppFieldname='tweet'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;},}}children={(field) => (<field.TextareaFieldlabel='Tweet'props={{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><form.AppForm><form.SubmitButton>Post Tweet</form.SubmitButton></form.AppForm></form>);}
Default
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';import { TextareaField } from '@/components/ui/shuip/tanstack-form/textarea-field';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { TextareaField },formComponents: { SubmitButton },});export default function TsfTextareaFieldExample() {const form = useAppForm({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'><form.AppFieldname='bio'validators={{onChange: ({ value }) => {if (!value) return 'Bio is required';if (value.length < 20) return 'Bio must be at least 20 characters';return undefined;},}}children={(field) => (<field.TextareaFieldlabel='Biography'description='Tell us about yourself'props={{ rows: 4, placeholder: 'Software engineer passionate about...' }}/>)}/><form.AppFieldname='feedback'children={(field) => (<field.TextareaFieldlabel='Feedback'description='Share your thoughts or suggestions'props={{ rows: 6, placeholder: 'Your feedback helps us improve...' }}/>)}/><form.AppForm><form.SubmitButton>Submit</form.SubmitButton></form.AppForm></form>);}
Markdown Preview
'use client';import { createFormHook, useStore } from '@tanstack/react-form';import { Card } from '@/components/ui/card';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';import { TextareaField } from '@/components/ui/shuip/tanstack-form/textarea-field';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { TextareaField },formComponents: { SubmitButton },});export default function TsfTextareaFieldMarkdownPreviewExample() {const form = useAppForm({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'><form.AppFieldname='content'validators={{onChange: ({ value }) => {if (!value) return 'Content is required';if (value.length < 10) return 'Content must be at least 10 characters';return undefined;},}}children={(field) => (<field.TextareaFieldlabel='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]',}}/>)}/></div><Card className='p-4 min-h-[300px]'>{content ? (<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><form.AppForm><form.SubmitButton>Publish Article</form.SubmitButton></form.AppForm></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