component

Textarea

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.

Tone

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>

Size

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>

Icons & addons (Affix)

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>

Character count

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.

0 / 280
---
// 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>

Auto-resize

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>

Focusing the field (React ref)

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.

Custom Styling

The passThrough prop provides escape hatches for each slot: style accepts a Panda CSS SystemStyleObject and props forwards arbitrary HTML attributes.

Custom style via passThrough

Custom props via passThrough

---
// 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>
  • Components
  • Blocks