A multi-line text field that mirrors the input component — the same tone color states, size system, background/border variants, and composable parts (<TextareaRoot> + <TextareaField>) with Affix support — but grows vertically. It ships with a rows prop, a native drag-resize handle, and an opt-in autoResize that grows the box to fit its content.
Apply semantic tone colors to indicate validation state or context.
---
// GENERATED from packages/demos/textarea/tone.demo.tsx — edit the source, not this file.
import Textarea from "@pindoba/astro-textarea";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column" })}>
<Textarea tone="neutral" placeholder="Neutral" />
<Textarea tone="primary" placeholder="Primary" />
<Textarea tone="success" placeholder="Success" />
<Textarea tone="danger" placeholder="Danger" />
<Textarea tone="warning" placeholder="Warning" />
</div>Textareas come in four sizes that share the same height system as inputs and buttons — the size sets the single-row minimum height the box grows from.
---
// GENERATED from packages/demos/textarea/size.demo.tsx — edit the source, not this file.
import Textarea from "@pindoba/astro-textarea";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column" })}>
<Textarea size="xs" placeholder="Extra Small" />
<Textarea size="sm" placeholder="Small" />
<Textarea size="md" placeholder="Medium" />
<Textarea size="lg" placeholder="Large" />
</div>To add icons or small controls inside the frame, compose the parts: <TextareaRoot> carries the chrome (size, tone, background, border) and <TextareaField> is the bare <textarea>; wrap each addon in <Affix>. side="start" (default) pins the addon to the top-left corner, side="end" to the top-right — leaving the bottom-right free for the native resize grabber. The <textarea> fills the whole frame and its text is indented to clear the affixes, so a tall box has no dead gutters. A click anywhere in the frame focuses the field.
Because affixes float over the editable text, keep corner addons compact — an icon, an icon-button. Wide content like a character counter belongs below the field so it never overlaps wrapped text.
---
// GENERATED from packages/demos/textarea/leading-trailing-sizes.demo.tsx — edit the source, not this file.
import { TextareaRoot, TextareaField } from "@pindoba/astro-textarea";
import Affix from "@pindoba/astro-affix";
import Stamp from "@pindoba/astro-stamp";
import { MessageSquare, Paperclip } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
const sizes = ["xs", "sm", "md", "lg"] as const;
---
<div class={stack({ gap: "md", direction: "column" })}>
{sizes.map((size) => (
<TextareaRoot size={size}>
<Affix>
<Stamp emphasis="ghost" border="muted">
<MessageSquare />
</Stamp>
</Affix>
<TextareaField rows={3} placeholder={`Message (${size})`} />
<Affix side="end">
<Stamp emphasis="ghost" border="muted">
<Paperclip />
</Stamp>
</Affix>
</TextareaRoot>
))}
</div>A counter (or any wide hint) reads best below the field, not overlaying the text. Rather than bake a footer slot into the textarea, pin it with the <Attachment> component — placement="bottom-end" + anchor="outside" floats the counter just below the bottom-right, clear of both the text and the resize grabber.
---
// GENERATED from packages/demos/textarea/character-count.demo.tsx — edit the source, not this file.
import Textarea from "@pindoba/astro-textarea";
import Attachment from "@pindoba/astro-attachment";
import { css } from "@pindoba/styled-system/css";
const MAX = 280;
const field = {
root: { style: { display: "block", width: "20rem", maxWidth: "100%" } },
} as const;
const counter = (over: boolean) =>
css({
fontSize: "xs",
fontVariantNumeric: "tabular-nums",
color: over ? "danger.text" : "neutral.text.muted",
background: "neutral.surface.hill",
borderRadius: "sm",
paddingInline: "2xs",
paddingBlock: "3xs",
});
const value = "";
---
<Attachment
placement="bottom-end"
anchor="outside"
passThrough={field}
data-ir-root="textarea/character-count"
>
<span slot="content" class={counter(value.length >= MAX)} data-ir="2">
<!--ir:3-->
{`${value.length} / ${MAX}`}
<!--/ir-->
</span>
<Textarea
rows={4}
fullWidth
maxlength={MAX}
placeholder="Write a message…"
data-ir="1"
/>
</Attachment>
<script>
import { hydrate } from "./_client/character-count.ts";
document
.querySelectorAll('[data-ir-root="textarea/character-count"]')
.forEach((root) => hydrate([root]));
</script>Set autoResize to grow the box to fit its content as the user types. Pair it with resize="none" so the native drag handle doesn’t fight the auto height.
---
// GENERATED from packages/demos/textarea/auto-resize.demo.tsx — edit the source, not this file.
import Textarea from "@pindoba/astro-textarea";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column" })}>
<Textarea
autoResize
resize="none"
rows={2}
placeholder="Type multiple lines…"
fullWidth
/>
</div>In React, ref on <Textarea> points at the native <textarea>, so you can focus or select the field without switching to the parts. Textarea is a forwardRef component, so this works on React 18 and React 19:
import { useRef } from "react";
import { Textarea } from "@pindoba/react-textarea";
export function Example() {
const ref = useRef<HTMLTextAreaElement>(null);
return (
<>
<Textarea ref={ref} placeholder="Name" />
<button onClick={() => ref.current?.focus()}>Focus</button>
</>
);
}
TextareaField takes a ref to the same element when you compose the parts.
The passThrough prop provides escape hatches for each slot: style accepts a Panda CSS SystemStyleObject and props forwards arbitrary HTML attributes.
---
// GENERATED from packages/demos/textarea/custom.demo.tsx — edit the source, not this file.
import Textarea from "@pindoba/astro-textarea";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Custom style via passThrough</h3>
<Textarea
placeholder="Custom root style"
passThrough={{
root: { style: { borderRadius: "full" } },
textarea: { style: { fontStyle: "italic" } },
}}
/>
</div>
<div>
<h3>Custom props via passThrough</h3>
<Textarea
placeholder="Message..."
passThrough={{
textarea: {
props: {
"aria-label": "Message textarea",
"data-testid": "message-textarea",
},
},
}}
/>
</div>
</div>