Skip to content
design
Components

Text area

A multi-line text field whose height the crew drag a whole line at a time, between a fewest and a most lines.

On this page
Spoken on every speaker when the alarm sounds.104 of 160 characters
A watch log remark and the announcement an anchor alarm speaks, with its character counter. Drag the handle under either field, or tab to it and press the arrow keys.
Code
import { FormField } from "@bosunkit/components/form-field";
import { TextArea } from "@bosunkit/components/text-area";
import {
  useId,
  useState,
  type ChangeEvent,
  type CSSProperties,
  type Dispatch,
  type ReactElement,
  type SetStateAction,
} from "react";

export const description: string =
  "A watch log remark and the announcement an anchor alarm speaks, with a character counter. Drag the handle under " +
  "either field, or tab to it and press the arrow keys: each grows a line at a time from three lines to five.";

const ANNOUNCEMENT_LIMIT: number = 160;

const HINT: CSSProperties = {
  display: "flex",
  justifyContent: "space-between",
  gap: "var(--space-sm)",
  fontFamily: "var(--font-mono)",
  fontSize: "0.75em",
  color: "var(--text-dim)",
};

/** The count keeps its figures and its unit on one line; the description beside it wraps instead. */
const COUNT: CSSProperties = { flex: "none", whiteSpace: "nowrap" };

export default function Showcase(): ReactElement {
  const announcementHintId: string = useId();
  const [announcement, setAnnouncement]: [string, Dispatch<SetStateAction<string>>] = useState<string>(
    "Anchor alarm. The vessel is 38 meters outside the swing circle. Check the position and start the engine.",
  );

  return (
    <div style={{ width: "100%", maxWidth: 560, display: "grid", gap: "var(--space-lg)" }}>
      <FormField label="Watch log remark">
        <TextArea
          aria-label="Watch log remark"
          placeholder="What happened on watch: sail changes, traffic, weather, anything the next watch should know."
          defaultValue={
            "02:10 Wind backed SW 18 kn, gusts 24. Took the second reef.\n" +
            "03:05 Ferry Pont-Aven passed 1.2 NM to port, CPA steady.\n" +
            "03:40 Barometer 1006 hPa, down 3 hPa in three hours."
          }
        />
      </FormField>

      <FormField label="Anchor alarm announcement">
        <TextArea
          aria-label="Anchor alarm announcement"
          aria-describedby={announcementHintId}
          maxLength={ANNOUNCEMENT_LIMIT}
          value={announcement}
          onChange={(event: ChangeEvent<HTMLTextAreaElement>): void => {
            setAnnouncement(event.target.value);
          }}
        />
        <div id={announcementHintId} style={HINT}>
          <span>Spoken on every speaker when the alarm sounds.</span>
          <span style={COUNT}>
            {announcement.length} of {ANNOUNCEMENT_LIMIT} characters
          </span>
        </div>
      </FormField>
    </div>
  );
}

A text area takes text that runs to several lines: a log remark, a handover note, the words an alarm announces. It starts three lines tall. The handle on its bottom edge makes it taller or shorter, one whole line at a time, from three lines to five; past the fifth line the text scrolls inside the field. The field always fills the width of its container and never resizes sideways, so a form keeps its columns however the crew size a field.

01Whole linesThe height is always a whole number of lines, plus the padding and the border, so no line is ever cut in half at the bottom edge.
02Up and down onlyThe field fills its column and cannot be dragged wider, so a resized field never pushes the rest of the form out of line.
03A target a gloved hand can hitThe handle is a 48 px target centered on the bottom edge, and the keyboard reaches it as a separator that reports its lines.

How it resizes

Dragging. Pressing the handle and moving down adds a line as the pointer passes each half-line, and moving up takes one away, between minRows and maxRows. The drag follows the pointer that started it, whether a mouse, a finger or a pen, until it is lifted. The height changes at once, with no animation, so there is nothing to slow down under reduced motion.

Range. By default the field runs from 3 to 5 lines. minRows and maxRows set another range: whole numbers, at least one line, and a most no smaller than the fewest. A range outside those rules throws when the field renders. rows with onRowsChange lets the caller hold the line count, and defaultRows starts the field's own count.

Keyboard and screen readers. The handle is a focusable separator with aria-orientation="horizontal". It reports its lines as aria-valuenow between aria-valuemin and aria-valuemax, is named "Field height" and reads "3 of 5 lines" in the crew's language. ArrowUp and ArrowDown step a line; Home and End go to the fewest and the most. Pass resizeLabel when a page holds several text areas, so each handle names its own field.

Text. The textarea takes its own props: value or defaultValue with onChange, placeholder, maxLength, name, readOnly, disabled and every aria-* attribute. A read-only field keeps its handle, so a long entry can be opened up to read. A disabled field disables its handle too. aria-invalid draws the border in the alarm color; put the message in a form field's error.

Examples

From three lines to five

Three lines to start, five at most: drag the handle down a line at a time. Text past the fifth line scrolls.
Code
import { TextArea } from "@bosunkit/components/text-area";
import type { ReactElement } from "react";

export const description: string =
  "Three lines to start, five at most: drag the handle down a line at a time. Text past the fifth line scrolls.";

export default function Default(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 420 }}>
      <TextArea aria-label="Remarks" placeholder="Remarks for the next watch" />
    </div>
  );
}

From two lines to eight

minRows and maxRows set the range: passage notes start at two lines and open to eight.
Code
import { TextArea } from "@bosunkit/components/text-area";
import type { ReactElement } from "react";

export const description: string =
  "minRows and maxRows set the range: passage notes start at two lines and open to eight.";

export default function Range(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 420 }}>
      <TextArea
        aria-label="Passage notes"
        minRows={2}
        maxRows={8}
        defaultValue={
          "Leave Camaret at 06:30 on the last of the ebb.\n" +
          "Raz de Sein at slack, 08:10. Keep 0.5 NM off La Vieille.\n" +
          "Audierne by noon; anchor off Sainte-Évette in 4 m, sand."
        }
      />
    </div>
  );
}

Lines held by the caller

4 lines
The caller holds rows: the buttons set the height, and a drag or the arrow keys report the new line count.
Code
import { Button } from "@bosunkit/components/button";
import { TextArea } from "@bosunkit/components/text-area";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The caller holds rows: the buttons set the height, and a drag or the arrow keys report the new line count.";

export default function Controlled(): ReactElement {
  const [rows, setRows]: [number, Dispatch<SetStateAction<number>>] = useState<number>(4);

  return (
    <div style={{ width: "100%", maxWidth: 420, display: "grid", gap: "var(--space-sm)" }}>
      <TextArea
        aria-label="Handover notes"
        rows={rows}
        onRowsChange={setRows}
        defaultValue={"Watermaker running since 05:00.\nPort genoa sheet chafing at the lead block."}
      />
      <div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
        <Button
          variant="ghost"
          size="sm"
          type="button"
          onClick={(): void => {
            setRows(3);
          }}
        >
          Fewest lines
        </Button>
        <Button
          variant="ghost"
          size="sm"
          type="button"
          onClick={(): void => {
            setRows(5);
          }}
        >
          Most lines
        </Button>
        <span style={{ fontFamily: "var(--font-mono)", fontSize: "0.75em", color: "var(--text-dim)" }}>
          {rows} lines
        </span>
      </div>
    </div>
  );
}

Read-only

A read-only field stays focusable and selectable, and its handle opens it up to read a long entry.
Code
import { TextArea } from "@bosunkit/components/text-area";
import type { ReactElement } from "react";

export const description: string =
  "A read-only field stays focusable and selectable, and its handle opens it up to read a long entry.";

export default function ReadOnly(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 420 }}>
      <TextArea
        aria-label="Signed log entry"
        readOnly
        defaultValue={
          "04:00 Handed over to Marta Lindqvist.\n" +
          "Position 48°13.4′N 004°29.9′W, course 215°, 6.2 kn.\n" +
          "Wind SW 16 kn, sea moderate, visibility good.\n" +
          "Second reef in. Engine off since 01:15.\n" +
          "Signed: Anneke de Vries, skipper."
        }
      />
    </div>
  );
}

Disabled

The passage has started, so its departure remarks are closed.
A disabled field and its handle are dimmed and skipped by the keyboard; the hint says why.
Code
import { FormField } from "@bosunkit/components/form-field";
import { TextArea } from "@bosunkit/components/text-area";
import type { ReactElement } from "react";

export const description: string =
  "A disabled field and its handle are dimmed and skipped by the keyboard; the hint says why.";

export default function Disabled(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 420 }}>
      <FormField label="Departure remarks">
        <TextArea
          aria-label="Departure remarks"
          aria-describedby="departure-remarks-hint"
          disabled
          defaultValue="Fuel full, water 340 l, crew of four."
        />
        <div
          id="departure-remarks-hint"
          style={{ fontFamily: "var(--font-mono)", fontSize: "0.75em", color: "var(--text-dim)" }}
        >
          The passage has started, so its departure remarks are closed.
        </div>
      </FormField>
    </div>
  );
}

Invalid, with its error

aria-invalid draws the border in the alarm color, and the form field's error says what to fix.
Code
import { FormField } from "@bosunkit/components/form-field";
import { TextArea } from "@bosunkit/components/text-area";
import type { ReactElement } from "react";

export const description: string =
  "aria-invalid draws the border in the alarm color, and the form field's error says what to fix.";

export default function Invalid(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 420 }}>
      <FormField label="Reason for the course change" error="Say why the course changed, such as traffic or a squall.">
        <TextArea aria-label="Reason for the course change" aria-invalid="true" required defaultValue="" />
      </FormField>
    </div>
  );
}

In a form field

Anything that differs from the last check.
Inside a form field the label sits above, the hint below, and the field takes the label as its name.
Code
import { FormField } from "@bosunkit/components/form-field";
import { TextArea } from "@bosunkit/components/text-area";
import type { ReactElement } from "react";

export const description: string =
  "Inside a form field the label sits above, the hint below, and the field takes the label as its name.";

export default function InFormField(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 420 }}>
      <FormField label="Engine room check">
        <TextArea
          aria-label="Engine room check"
          aria-describedby="engine-room-check-hint"
          placeholder="Oil, coolant, belts, bilge, stern gland"
        />
        <div
          id="engine-room-check-hint"
          style={{ fontFamily: "var(--font-mono)", fontSize: "0.75em", color: "var(--text-dim)" }}
        >
          Anything that differs from the last check.
        </div>
      </FormField>
    </div>
  );
}

Props

PropTypeDefaultDescription
refRef<HTMLTextAreaElement> | undefined–The textarea element.
rowsnumber | undefined–How many lines tall the field is, when the caller holds it; kept between minRows and maxRows.
defaultRowsnumber | undefined–How many lines tall the field starts when the caller does not hold rows; minRows by default.
onRowsChange((rows: number) => void) | undefined–Called with the new line count when the crew drag the handle or step it from the keyboard.
minRowsnumber | undefinedDEFAULT_MIN_ROWSThe fewest lines the field shows: a whole number, at least 1; 3 by default.
maxRowsnumber | undefinedDEFAULT_MAX_ROWSThe most lines the field grows to before its text scrolls: a whole number, at least minRows; 5 by default.
resizeLabelstring | undefined–The drag handle's accessible name, translated by the caller; "Field height" in the crew's language by default.
classNamestring | undefined–Classes added to the root, which holds the field and its handle.
testIdstring | undefined–Becomes data-testid on the root; the handle is ${testId}--handle.

Also takes every attribute of Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "rows" | "cols">.

Do
Use a text area for a remark that runs to several lines. The crew see the whole entry and can open the field further.
Don't
Don't put a remark in a single-line input. Everything after the first few words is out of sight.
  • Form field: the label above a text area and the error under it.
  • Input: the single-line field for a name, a number or a short value.
  • Schema form: the form that draws every long-text field of an entity as a text area.