Skip to content
cennge-ui
GitHubv0.3.1, changelog

Textarea

A field for several lines of text that grows as you write, between a smallest and a largest height.

141 characters
The summary line has 79 characters; keep it to 72
  1. Add a line to the release notes: the field grows with it, up to 8 rows, then scrolls inside.
  2. There is no resize handle; the text sets the height.
  3. Shorten the commit's first line to 72 characters: the error turns back into the hint.
Live: the component itself, not a picture of it.

Field marks

Import
import { Textarea } from "cennge-ui";
Size
3.3 KB gzip for this import, its dependencies aside
Pattern
Native <textarea> with a <label>
State
Controlled with value and onValueChange, or uncontrolled with defaultValue
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Height
From minRows (3) to maxRows (8) lines, then it scrolls
Resize handle
None: the height follows the text
Tests
Unit tests

When to use#

Use a Textarea for text that runs to more than a line: release notes, a comment, a description. It starts at minRows lines and grows with the text up to maxRows, so a short note takes little room and a long one does not push the page down without end.

Similar#

  • Input is one line, with a clear button and room for icons; a Textarea has neither.
  • TagsInput turns typed words into separate tags instead of keeping one block of text.

Rows to grow between#

minRows sets the height of the empty field and maxRows the most it grows; past that the text scrolls inside the field. Both count lines of text.

Two rows when empty, four at most; scroll for the rest
rows.tsx
import { Textarea } from "cennge-ui";

const why = [
  "Dark-first, tactile look.",
  "Recolour without a rebuild.",
  "Updates instead of copy-paste.",
  "Accessible by default.",
  "Light on dependencies.",
  "Server Components ready.",
].join("\n");

export default function ReadmeSection() {
  return (
    <Textarea
      label="Why cennge-ui"
      minRows={2}
      maxRows={4}
      defaultValue={why}
      description="Two rows when empty, four at most; scroll for the rest"
      className="w-full max-w-md"
    />
  );
}

Count characters#

description takes any node, so a live count fits there. While isInvalid, errorMessage takes its place.

82 / 160
counter.tsx
import { useState } from "react";
import { Textarea } from "cennge-ui";

const limit = 160;

export default function RepositoryDescription() {
  const [description, setDescription] = useState("Crypto project screener: FastAPI + CoinGecko backend, Next.js + shadcn/ui frontend");
  const over = description.length - limit;

  return (
    <Textarea
      label="Description"
      minRows={2}
      value={description}
      onValueChange={setDescription}
      description={`${description.length} / ${limit}`}
      isInvalid={over > 0}
      errorMessage={`${over} ${over === 1 ? "character" : "characters"} over the limit`}
      className="w-full max-w-md"
    />
  );
}

Send with a shortcut#

Every other prop goes to the <textarea>, onKeyDown included. Here Ctrl+Enter or ⌘+Enter posts the comment, while Enter alone still starts a new line.

CtrlEnter or Command Enter sends it
send-shortcut.tsx
import { useState } from "react";
import { Button, Kbd, Textarea, toast } from "cennge-ui";

export default function CommentBox() {
  const [comment, setComment] = useState("");

  function send() {
    if (!comment.trim()) return;
    toast.success("Comment posted", { description: "On ai-niche-scout" });
    setComment("");
  }

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Textarea
        label="Comment"
        placeholder="Leave a comment"
        minRows={2}
        maxRows={6}
        value={comment}
        onValueChange={setComment}
        onKeyDown={(event) => {
          if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
            event.preventDefault();
            send();
          }
        }}
        description={
          <>
            <Kbd size="sm" keys={["ctrl", "enter"]} /> or <Kbd size="sm" keys={["command", "enter"]} /> sends it
          </>
        }
      />
      <Button color="primary" className="self-end" isDisabled={!comment.trim()} onClick={send}>
        Comment
      </Button>
    </div>
  );
}

Accessibility#

The label is a <label for> the textarea, and the description or the error is its aria-describedby. isInvalid sets aria-invalid and isRequired the native required attribute, so inside a Form a named, required field that is empty shows the browser's message under it.

Styling#

It shares the Input's box: the same variant, color, size and radius. className goes to the outer wrapper; classNames reaches base, label, inputWrapper, input, description and errorMessage.

Props#

PropTypeDefault
label
React.ReactNode
–
description
React.ReactNode
–
errorMessage
React.ReactNode
–
value
string
–
defaultValue
string
–
onValueChange
(value: string) => void
–
minRows
numberGrows with the text between these line counts.
3
maxRows
number
8
isInvalid
boolean
–
isRequired
boolean
–
isDisabled
boolean
–
variant
"bordered""flat""faded""underlined"
"flat"
color
"default""primary""secondary""success""warning""danger""info"
"primary"
size
"sm""md""lg"
"md"
radius
"none""sm""md""lg""full"
"md"
classNames
Partial<Record<TextareaSlots, string>>
–

Also takes every attribute of <textarea>, except color, value, defaultValue, which mean something else here.