Textarea Field

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

npx shadcn@latest add @shuip/tsf-textarea-field
pnpm dlx shadcn@latest add @shuip/tsf-textarea-field
bun x shadcn@latest add @shuip/tsf-textarea-field
'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 render={<InputGroupButton aria-label='Info' size='icon-xs' />}>
                <InfoIcon />
              </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>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

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