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>
<InputGroupTextarea
id={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 && (
<FieldError
className='text-xs text-left'
errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}
/>
)}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</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 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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4 w-full max-w-lg'
>
<div className='space-y-2'>
<form.AppField
name='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.TextareaField
label='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>
<span
className={`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

Loading...
'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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='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.TextareaField
label='Biography'
description='Tell us about yourself'
props={{ rows: 4, placeholder: 'Software engineer passionate about...' }}
/>
)}
/>
<form.AppField
name='feedback'
children={(field) => (
<field.TextareaField
label='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

Loading...
'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 (
<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'>
<form.AppField
name='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.TextareaField
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]',
}}
/>
)}
/>
</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

On this page