Skip to content
UI Kit
Accent color

Textarea

Multi-line text that grows with its content, with a counter, a footer and an error as text.

One of 9 components in Forms and inputs

2 variants

Variants

States

Grows while you type, the counter turns strong near the limit and red beyond it, and the box shakes once when you go over.

Composer

A footer row with tools and the send button, as in comments and chats.

Details

What Textarea brings along, read from the registry and the source.

Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/textarea.tsx
Brings along
utilskitinput
npm packages
None
Languages
English and German, 2 texts of its own
Studio
Adjust in code

Props

The TextareaProps interface from components/ui/textarea.tsx, with the comments from the code. Also takes every prop of FieldOwnProps, Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength'>.

  • autoResizeoptional
    boolean

    Grows with its content between minRows and maxRows (CSS field-sizing, falls back to rows)

  • minRowsoptional
    number
  • maxRowsoptional
    number
  • maxLengthoptional
    number

    Shows a counter. Going over is allowed while typing and shown as an error, so nothing gets cut off silently.

  • footeroptional
    React.ReactNode

    Something in the footer row, e.g. formatting hints or an attach button

  • invalidoptional
    boolean
  • refoptional
    React.Ref<HTMLTextAreaElement>

Built-in texts

Textarea writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 2 texts
leftEnglish characters leftGerman Zeichen übrig
overEnglish characters too manyGerman Zeichen zu viel

Accessibility

Read from the source of the component.

  • Sets aria-describedby and aria-invalid, so screen readers announce purpose and state.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 86 lines
components/ui/textarea.tsx
'use client';

import { useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { controlBox, useControl, useShake, withField, type FieldOwnProps } from '@/components/ui/input';

const TEXTS = {
  en: { left: 'characters left', over: 'characters too many' },
  de: { left: 'Zeichen übrig', over: 'Zeichen zu viel' },
};

export interface TextareaProps extends FieldOwnProps, Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength'> {
  /** Grows with its content between minRows and maxRows (CSS field-sizing, falls back to rows) */
  autoResize?: boolean;
  minRows?: number;
  maxRows?: number;
  /** Shows a counter. Going over is allowed while typing and shown as an error, so nothing gets cut off silently. */
  maxLength?: number;
  /** Something in the footer row, e.g. formatting hints or an attach button */
  footer?: React.ReactNode;
  invalid?: boolean;
  ref?: React.Ref<HTMLTextAreaElement>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

function TextareaBox({ autoResize = true, minRows = 3, maxRows = 10, maxLength, footer, invalid, labels, className, value, defaultValue, onChange, ref, ...rest }: Omit<TextareaProps, keyof FieldOwnProps>) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const c = useControl({ ...rest, invalid });
  const [inner, setInner] = useState(String(defaultValue ?? ''));
  const text = value !== undefined ? String(value) : inner;
  const over = maxLength !== undefined && text.length > maxLength;
  const left = maxLength !== undefined ? maxLength - text.length : 0;
  const fmt = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
  const showFooter = maxLength !== undefined || footer;
  const boxRef = useRef<HTMLDivElement>(null);
  // One shake when the text goes over the limit or the field turns invalid
  useShake(boxRef, c.invalid || over);
  return (
    <div ref={boxRef} data-invalid={c.invalid || over || undefined} data-disabled={c.disabled || undefined} className={cn('flex w-full flex-col overflow-hidden rounded-lg', controlBox, className)}>
      <textarea
        {...rest}
        ref={ref}
        id={c.id}
        value={value}
        defaultValue={value === undefined ? defaultValue : undefined}
        onChange={(e) => {
          setInner(e.target.value);
          onChange?.(e);
        }}
        disabled={c.disabled}
        required={c.required}
        aria-invalid={c.invalid || over || undefined}
        aria-describedby={c['aria-describedby']}
        rows={minRows}
        style={{ minHeight: `calc(${minRows} * 1.5em + 1.25rem)`, maxHeight: `calc(${maxRows} * 1.5em + 1.25rem)` }}
        className={cn(
          'w-full resize-none bg-transparent px-3.5 py-2.5 text-base leading-[1.5] text-inherit outline-none placeholder:text-subtle-foreground disabled:cursor-not-allowed sm:text-sm sm:leading-[1.5]',
          autoResize ? '[field-sizing:content]' : 'resize-y',
          'kit-scrollbar',
        )}
      />
      {showFooter && (
        <div className="flex min-h-10 items-center justify-between gap-3 px-2 pb-2 ps-3.5">
          <div className="flex min-w-0 flex-1 items-center gap-1">{footer}</div>
          {maxLength !== undefined && (
            <p className={cn('ms-auto shrink-0 text-xs tabular-nums transition-colors duration-300', over ? 'font-medium text-destructive' : left <= maxLength * 0.1 ? 'text-foreground' : 'text-subtle-foreground')}>
              <span aria-hidden>{over ? `−${fmt(-left)}` : `${fmt(text.length)} / ${fmt(maxLength)}`}</span>
              <span className="sr-only">{over ? `${fmt(-left)} ${t.over}` : `${fmt(left)} ${t.left}`}</span>
            </p>
          )}
        </div>
      )}
    </div>
  );
}

/**
 * Multi-line text that grows with its content, with an optional counter and footer.
 *   <Textarea label="Message" maxLength={500} hint="We reply within a day." />
 */
export function Textarea({ label, hint, error, optional, labelAction, className, ...rest }: TextareaProps) {
  const wrapped = Boolean(label || hint || error);
  return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <TextareaBox {...rest} className={wrapped ? undefined : className} />);
}

Frequent questions

How do I install Textarea?

With aiw add @ai-website/textarea. The command puts components/ui/textarea.tsx into your project, the code is yours from then on. It brings along input. It needs no further npm packages.

Which variants does Textarea have?

2 variants, States and Composer.

Can I translate the texts of Textarea?

Yes. Textarea carries 2 texts of its own in English and German, such as “characters left” and “characters too many”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Textarea?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Textarea with aiw_components and install it with aiw_add @ai-website/textarea.

Often searched

Jump to

Copied to the clipboard