Textarea
A native textarea element. Built with Base UI and Tailwind CSS. Copy-paste ready.
import { Textarea } from "@/components/ui/textarea";
export default function Particle() {
return <Textarea placeholder="Type your message here" />;
}
Installation
pnpm dlx shadcn@latest add @cnippet/textarea
Usage
import { Textarea } from "@/components/ui/textarea"<Textarea />Examples
Sizes
Renders the sm, default, and lg size variants to show how the textarea scales across layout contexts.
Small
Large
import { Textarea } from "@/components/ui/textarea";
export default function Particle() {
return (
<div className="mb-1 space-y-2 pt-6 text-muted-foreground text-sm">
<p>Small</p>
<Textarea placeholder="Type your message here" size="sm" />
<p>Large</p>
<Textarea placeholder="Type your message here" size="lg" />
</div>
);
}
Disabled
Prevents all interaction while keeping the current content visible in the field.
import { Textarea } from "@/components/ui/textarea";
export default function Particle() {
return <Textarea disabled placeholder="Can't type here" />;
}
With Label
Associates a Label with the textarea using htmlFor for accessible labelling without a Field wrapper.
import { useId } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function Particle() {
const id = useId();
return (
<div className="flex flex-col items-start gap-2">
<Label htmlFor={id}>Message</Label>
<Textarea id={id} placeholder="Type your message here" />
</div>
);
}
With Description
Adds a short description paragraph below the textarea to provide usage guidance or character constraints.
Type your message and press enter to send.
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
export function Pattern() {
return (
<div className="mx-auto w-full max-w-xs">
<Field className="w-full">
<FieldLabel htmlFor="textarea-with-desc">Feedback</FieldLabel>
<Textarea
id="textarea-with-desc"
placeholder="Type your message here…"
rows={6}
/>
<FieldDescription>
Type your message and press enter to send.
</FieldDescription>
</Field>
</div>
);
}
With Character Count
Displays a live character counter below the textarea that updates as the user types, useful for fields with a maximum length.
"use client";
import { useCallback, useRef, useState } from "react";
import { Field, FieldLabel } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
const MAX_CHARS = 280;
export function Pattern() {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
if (newValue.length <= MAX_CHARS) {
setValue(newValue);
}
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}
},
[],
);
const remaining = MAX_CHARS - value.length;
const isNearLimit = remaining <= 20;
const isAtLimit = remaining === 0;
return (
<div className="mx-auto w-full max-w-xs">
<Field className="w-full">
<div className="flex w-full items-center justify-between">
<FieldLabel htmlFor="auto-resize-textarea">Bio</FieldLabel>
<span
className={`text-xs tabular-nums ${
isAtLimit
? "font-semibold text-destructive"
: isNearLimit
? "text-warning"
: "text-muted-foreground"
}`}
>
{value.length}/{MAX_CHARS}
</span>
</div>
<Textarea
className="resize-none overflow-hidden"
id="auto-resize-textarea"
onChange={handleChange}
placeholder="Tell us about yourself..."
ref={textareaRef}
rows={2}
value={value}
/>
</Field>
</div>
);
}
Form Integration
Wraps the textarea in Form and Field for required validation and a visible FieldError displayed on submit.
"use client";
import type { FormEvent } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
export default function Particle() {
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
setLoading(true);
await new Promise((r) => setTimeout(r, 800));
setLoading(false);
alert(`Message: ${formData.get("message") || ""}`);
};
return (
<Form className="flex w-full max-w-64 flex-col gap-4" onSubmit={onSubmit}>
<Field>
<FieldLabel>Message</FieldLabel>
<Textarea
name="message"
placeholder="Type your message here"
required
/>
<FieldError>This field is required.</FieldError>
</Field>
<Button loading={loading} type="submit">
Submit
</Button>
</Form>
);
}
Word Count
Displays live word and character counts below the textarea as the user types, useful for length-aware fields like bios or summaries.
"use client";
import { useState } from "react";
import { Textarea } from "@/components/ui/textarea";
export function Pattern() {
const [value, setValue] = useState("");
const wordCount = value.trim() ? value.trim().split(/\s+/).length : 0;
const charCount = value.length;
return (
<div className="w-full max-w-xs">
<Textarea
onChange={(e) => setValue(e.target.value)}
placeholder="Start writing. The field grows as you type…"
value={value}
/>
<div className="mt-1.5 flex items-center justify-between text-muted-foreground text-xs">
<span>
{wordCount} {wordCount === 1 ? "word" : "words"}
</span>
<span>{charCount} chars</span>
</div>
</div>
);
}
Write / Preview
A two-tab toggle that switches between an editable textarea and a read-only preview panel, mimicking a markdown editor's split view.
"use client";
import { useState } from "react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
const INITIAL = `## What's new in v2.4
This release ships the **new dashboard** layout and several performance improvements.
### Changes
- Redesigned sidebar navigation
- Improved data table with column sorting
- Fixed auth token refresh on mobile
`;
export function Pattern() {
const [content, setContent] = useState(INITIAL);
return (
<div className="w-full max-w-sm">
<Tabs defaultValue="write">
<TabsList className="mb-2" variant="underline">
<TabsTab value="write">Write</TabsTab>
<TabsTab value="preview">Preview</TabsTab>
</TabsList>
<TabsPanel value="write">
<Textarea
onChange={(e) => setContent(e.target.value)}
placeholder="Write markdown here…"
style={{ minHeight: "10rem" }}
value={content}
/>
</TabsPanel>
<TabsPanel value="preview">
<div className="min-h-40 rounded-lg border border-input bg-background p-3">
{content ? (
<p className="whitespace-pre-wrap text-foreground/80 text-xs leading-relaxed">
{content}
</p>
) : (
<p className="text-muted-foreground text-xs">
Nothing to preview.
</p>
)}
</div>
</TabsPanel>
</Tabs>
</div>
);
}
Support Ticket
A support form with pill-style category chips (Bug Report, Feature Request, etc.) and a message textarea. Submitting shows an inline confirmation state without navigating away.
"use client";
import type { FormEvent } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
const categories = [
"Bug Report",
"Feature Request",
"Billing",
"Account",
"Other",
];
export function Pattern() {
const [category, setCategory] = useState("");
const [message, setMessage] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
await new Promise((r) => setTimeout(r, 700));
setLoading(false);
setSent(true);
};
if (sent) {
return (
<div className="flex w-full max-w-xs flex-col items-center gap-3 rounded-xl border border-border p-6 text-center">
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-500/10">
<span className="text-emerald-500 text-xl">✓</span>
</div>
<p className="font-semibold text-sm">Ticket submitted!</p>
<p className="text-muted-foreground text-xs">
We'll get back to you within 24 hours.
</p>
<Button
onClick={() => {
setSent(false);
setCategory("");
setMessage("");
}}
size="sm"
variant="outline"
>
Submit another
</Button>
</div>
);
}
return (
<form
className="flex w-full max-w-xs flex-col gap-4"
onSubmit={handleSubmit}
>
<Field>
<FieldLabel>Category</FieldLabel>
<div className="flex flex-wrap gap-1.5">
{categories.map((c) => (
<button
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
category === c
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background text-muted-foreground hover:border-ring"
}`}
key={c}
onClick={() => setCategory(c)}
type="button"
>
{c}
</button>
))}
</div>
</Field>
<Field>
<FieldLabel>Message</FieldLabel>
<Textarea
minLength={10}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your issue in detail…"
required
value={message}
/>
<p className="text-right text-muted-foreground text-xs">
{message.length} / 500
</p>
</Field>
<Button disabled={!category || loading} loading={loading} type="submit">
Submit Ticket
</Button>
</form>
);
}
Chat Composer
An inline chat thread with a growing message textarea and action buttons for attachment, emoji, and send. Pressing Enter sends the message and appends a new bubble to the thread.
Alice Chen
Online
"use client";
import { PaperclipIcon, SendIcon, SmileIcon } from "lucide-react";
import type { KeyboardEvent } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
type Message = {
id: number;
own: boolean;
text: string;
time: string;
};
const INITIAL_MESSAGES: Message[] = [
{
id: 1,
own: false,
text: "Hey! Can you review the PR I just opened?",
time: "2:30 PM",
},
{
id: 2,
own: true,
text: "Sure, I'll take a look this afternoon.",
time: "2:31 PM",
},
{
id: 3,
own: false,
text: "Thanks! No rush, just want fresh eyes on it.",
time: "2:32 PM",
},
];
export function Pattern() {
const [message, setMessage] = useState("");
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const send = () => {
if (!message.trim()) return;
const now = new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
setMessages((prev) => [
...prev,
{ id: Date.now(), own: true, text: message.trim(), time: now },
]);
setMessage("");
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
return (
<div className="flex w-full max-w-xs flex-col overflow-hidden rounded-xl border border-border">
<div className="border-border border-b bg-muted/30 px-3 py-2">
<p className="font-medium text-sm">Alice Chen</p>
<p className="text-emerald-500 text-xs">Online</p>
</div>
<div className="flex min-h-48 flex-col justify-end gap-2 bg-muted/10 p-3">
{messages.map((m) => (
<div
className={`flex flex-col gap-0.5 ${m.own ? "items-end" : "items-start"}`}
key={m.id}
>
<div
className={`max-w-[80%] rounded-xl px-3 py-1.5 text-xs ${
m.own
? "rounded-tr-sm bg-primary text-primary-foreground"
: "rounded-tl-sm border border-border bg-background"
}`}
>
{m.text}
</div>
<span className="text-[10px] text-muted-foreground">{m.time}</span>
</div>
))}
</div>
<div className="border-border border-t bg-background p-2">
<div className="flex items-end gap-1.5">
<Textarea
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message… (↵ to send)"
size="sm"
style={{ minHeight: "2.5rem", resize: "none" }}
value={message}
/>
<div className="flex items-center gap-0.5">
<Button className="size-8 p-0" size="sm" variant="ghost">
<PaperclipIcon className="size-3.5" />
</Button>
<Button className="size-8 p-0" size="sm" variant="ghost">
<SmileIcon className="size-3.5" />
</Button>
<Button
className="size-8 p-0"
disabled={!message.trim()}
onClick={send}
size="sm"
>
<SendIcon className="size-3.5" />
</Button>
</div>
</div>
</div>
</div>
);
}
Release Notes Editor
A release notes form with a version title input, a character-capped markdown textarea, and Save Draft / Publish actions. The header badge reflects the current publish status.
Release Notes
Draft"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
const MAX_CHARS = 2000;
export function Pattern() {
const [title, setTitle] = useState("v2.4.0 — New Dashboard");
const [notes, setNotes] = useState(
`## What's new\n\n- Redesigned dashboard layout with improved navigation\n- Performance improvements across all pages\n- Fixed critical authentication bug on mobile`,
);
const [status, setStatus] = useState<"draft" | "published">("draft");
return (
<div className="flex w-full max-w-xs flex-col gap-4">
<div className="flex items-center justify-between">
<p className="font-semibold text-sm">Release Notes</p>
<Badge
size="sm"
variant={status === "published" ? "success" : "secondary"}
>
{status === "published" ? "Published" : "Draft"}
</Badge>
</div>
<Separator />
<Field>
<FieldLabel>Version title</FieldLabel>
<Input
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. v2.4.0 — Feature name"
value={title}
/>
</Field>
<Field>
<FieldLabel>Release notes</FieldLabel>
<Textarea
maxLength={MAX_CHARS}
onChange={(e) => setNotes(e.target.value)}
placeholder="Describe what changed in this release…"
style={{ minHeight: "8rem" }}
value={notes}
/>
<div className="flex items-center justify-between text-muted-foreground text-xs">
<span>Markdown supported</span>
<span
className={notes.length > MAX_CHARS * 0.9 ? "text-amber-500" : ""}
>
{notes.length} / {MAX_CHARS}
</span>
</div>
</Field>
<div className="flex gap-2">
<Button
className="flex-1"
onClick={() => setStatus("draft")}
size="sm"
variant="outline"
>
Save Draft
</Button>
<Button
className="flex-1"
onClick={() => setStatus("published")}
size="sm"
>
Publish
</Button>
</div>
</div>
);
}

