Textarea React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
An auto-growing React textarea with a character counter, an optional resize handle, a toolbar slot and mod+enter to submit.
Type and the box grows line by line from minRows up to maxRows, then scrolls inside; growth is instant, so typing never lags, and the caret stays in view because the box grows, not a scroller. Enter makes a new line and Tab moves on, like a native textarea. Press ⌘ Enter (Ctrl Enter elsewhere) to submit: the playground's Post checks for at least 40 characters, slides the error in under the box, and sends once it is long enough; submitShortcut="enter" makes it chat style, with Shift Enter for a new line. Use an example (Example on a phone) fills a long description so you can watch it grow to maxRows and scroll; Clear clears it. With a limit and showCount the counter turns amber at 90% and red at the limit. With resizable, a grip in the corner lets a mouse drag the box taller and that height sticks as the new floor. A click anywhere in the box, even in the toolbar's empty space, lands in the text. On a phone the text is 16px so iOS never zooms, the return key makes new lines, toolbar buttons keep 44px targets and the grip hides.
Textarea is a free React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add textarea, together with Field, useControllableState, useShortcut and UIProvider.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add textareaUsage
Import Textarea from @/components/ui/textarea.
import { useState } from "react"
import { Paperclip, SendHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
export function ListingDescription() {
return (
<form action="/listings" method="post">
<Textarea
name="description"
label="Listing description"
description="At least 40 characters."
placeholder="Describe the item: condition, size, why you are selling."
required
minLength={40}
showCount
maxLength={9000}
fieldProps={{ counterLabel: (left) => `${left} characters left` }}
/>
</form>
)
}
export function Composer({ onSend }: { onSend: (text: string) => void }) {
const [text, setText] = useState("")
const send = () => {
if (!text.trim()) return
onSend(text.trim())
setText("")
}
return (
<Textarea
aria-label="Message"
placeholder="Write a message…"
value={text}
onValueChange={setText}
minRows={1}
maxRows={6}
onSubmitShortcut={send}
footer={
<>
<Button variant="ghost" size="icon-sm" radius="full" aria-label="Attach">
<Paperclip />
</Button>
<Button size="sm" radius="full" className="ms-auto" rightIcon={<SendHorizontal />} onClick={send}>
Send
</Button>
</>
}
/>
)
}Props
labeldefault""ReactNodeThe label, linked with htmlFor. Leave it empty for a bare box, e.g. inside a <Field> that has its own label.
descriptiondefault""ReactNodeHelper text under the box, linked with aria-describedby. The error takes its place while it shows.
errordefault""ReactNodeReplaces the description with an alert icon and the text, slides in and marks the box invalid (red ring, aria-invalid).
requireddefaultfalsebooleanThe required mark after the label and required on the textarea.
invaliddefaultfalsebooleanThe invalid look and aria-invalid without a message, for forms that show errors elsewhere.
disableddefaultfalsebooleanDims the box and its label and blocks typing. Not submitted with the form, like any disabled field.
readOnlydefaultfalsebooleanThe text stays visible, selectable and scrollable but cannot change; no hover tint and no resize grip. Good for terms or a sent message.
placeholderdefault""stringShown while the box is empty, in the muted text color.
defaultValuedefault""stringThe starting text while uncontrolled. Where the browser has CSS field-sizing the box fits it from the first paint, before any JavaScript runs.
valuestringThe text, to control it from outside; pair it with onValueChange. A controlled value past maxLength shows as given (the counter turns red) and is never cut silently.
onValueChange(value: string) => voidCalled with the new text on every change, controlled or not.
onChange(event: ChangeEvent<HTMLTextAreaElement>) => voidThe native change event, untouched, so {...register("description")} from react-hook-form works as on a plain textarea.
sizedefault"md""sm" | "md" | "lg"Text 13 / 14 / 15px (16px on phones, so iOS never zooms) with matching padding. One row is as tall as an input of the same size: 32 / 40 / 48px, 44px for md on touch; sm grows to 36px on phones, where its text is 16px.
variantdefault"soft""soft" | "outline" | "underline" | "ghost"soft is a filled well that works on every surface, outline a bordered box, underline a bottom line that grows a bar from the center on focus, ghost is invisible until hovered (inline edits, toolbars).
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape, scaled per size like the button's (md is 8 / 10 / 12px). full is half a one-row height (16 / 20 / 24px; 18px for sm on phones, 22px for md on touch), so a one-row composer is a pill and a taller box never turns into an oval. Ignored by underline.
minRowsdefault3numberThe height when empty, in rows of text. 1 makes a composer that starts as tall as an input.
maxRowsdefault8numberGrows up to this many rows, then scrolls inside. 0 means no limit; a value below minRows counts as minRows.
autosizedefaulttruebooleanGrow with the text. Uses CSS field-sizing where the browser has it and measures once per change elsewhere. false keeps the box at minRows and scrolls.
resizabledefaultfalsebooleanA vertical resize grip for mouse and trackpad (hidden on touch). With autosize the dragged height becomes the new floor and the box still grows past it when the text needs more.
showCountdefaultfalsebooleanThe "count / max" counter at the end of the message row, amber from 90% and red at the limit. Needs maxLength.
maxLengthdefault0numberThe native character limit; 0 means none. Screen readers hear how many characters are left once 10% or less remains.
submitShortcutdefault"mod+enter"stringThe combo that submits, in mod+k notation: ⌘ Enter on Apple devices, Ctrl Enter elsewhere. It calls onSubmitShortcut, else form.requestSubmit(). "enter" makes it chat style (Shift Enter for a new line; touch keyboards keep making new lines), "" turns it off. Announced with aria-keyshortcuts; shown nowhere by default (the demo shows a Kbd hint).
onSubmitShortcut(value: string, event: KeyboardEvent<HTMLTextAreaElement>) => voidRuns on the shortcut with the current text instead of submitting the form. The chat example sends the message with it.
headerReactNodeA row inside the box above the text: attachment chips, a quoted reply. A click on its empty space lands in the text.
footerReactNodeA toolbar inside the box under the text: attach, emoji, send. Buttons keep their own 44px hit areas on touch; a click on the empty space lands in the text.
idstringThe textarea's id. Defaults to useId(); inside a <Field> the Field's id wins. ref and every other native prop (name, autoComplete, spellCheck, autoFocus, onBlur, aria-*, data-*) land on the <textarea> too; inputMode defaults to text and enterKeyHint to enter.
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel, counterLabel (what screen readers hear near the limit).
classNamesPartial<Record<"field" | "label" | "description" | "error" | "counter" | "box" | "textarea" | "header" | "footer", string>>Extra classes for the inner parts.
classNamestringClasses for the outermost element: the Field root when there is a label, description, error or counter, else the box.
Examples
Counter and minimum length
showCount with a limit, a 40-character minimum checked on blur, and the counter turning amber near 120.
Chat composer
minRows 1 to maxRows 6 with attachment chips in header and a toolbar in footer. onSubmitShortcut sends; switch to Enter for chat style.
Autosize, fixed and resizable
Grows with the text, stays fixed and scrolls, or takes a dragged height as its new floor.
Variants and sizes
soft, outline, underline and ghost in sm, md and lg. One row is as tall as an input of the same size.
Read-only terms
readOnly at maxRows 5: selectable, scrollable, not editable.
States
Required with a hint, an error, invalid without a message, and disabled.
In a horizontal form
Inside a FieldGroup the label moves beside the box once the form is wide enough and stacks on a phone. Cancel resets the form, ⌘ Enter saves it.
Dependencies
Files
- components/ui/textarea.tsx
FAQ
Is the React Textarea component free?
Yes. The source is free and needs no account: copy it from the Code tab or install it with the CLI.
How do I install Textarea?
Run npx wingo-ui@latest add textarea. It also adds the Wingo UI items it builds on (Field, useControllableState, useShortcut and UIProvider). With shadcn, run npx shadcn@latest add https://wingo-ui.com/r/textarea.json.
How does Textarea behave on phones?
Use an example (Example on a phone) fills a long description so you can watch it grow to maxRows and scroll; Clear clears it. On a phone the text is 16px so iOS never zooms, the return key makes new lines, toolbar buttons keep 44px targets and the grip hides.
See also
Used in
More React input components
Articles using this component
- Nextjs Contact Form That Sends Email and Blocks Spam
Build it: screens and apps10 min read
- React Form Components: Accessible Fields, Zod and Mobile
Component guides26 min read