Forms

Textarea

Multiline text field with a floating label

Preview

Message

You can enter up to 200 characters

size
label
required
error
disabled
counter
counter.max
counter.threshold

Code

import css from "./Textarea.module.css";
import { useEffect, useId, useRef, useState } from "react";

type Size = "xs" | "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface TextareaCounter {
  max: number;
  threshold?: number;
  remaining: (left: number) => string;
  exceeded: (over: number) => string;
  description: (max: number) => string;
}

interface TextareaProps {
  name: string;
  size: Size | ResponsiveSize;
  label: string;
  required?: boolean;
  error?: boolean;
  disabled?: boolean;
  counter?: TextareaCounter;
  value: string;
  onChange: (value: string) => void;
}

const STATUS_DELAY = 1000;

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function Textarea({
  name,
  size,
  label,
  required,
  error,
  disabled,
  counter,
  value,
  onChange,
}: TextareaProps) {
  const [focused, setFocused] = useState(false);
  const [tabbed, setTabbed] = useState(false);
  const pointer = useRef(false);
  const fieldId = useId();
  const active = focused || value.length > 0;

  const [status, setStatus] = useState("");
  const mounted = useRef(false);
  const descriptionId = `${fieldId}-description`;
  const count = value.length;
  const left = counter ? counter.max - count : 0;
  const over = left < 0;
  const shown =
    counter !== undefined &&
    (!counter.threshold || count >= (counter.max * counter.threshold) / 100);
  const text = counter ? (over ? counter.exceeded(-left) : counter.remaining(left)) : "";

  const onFocus = () => {
    setFocused(true);
    setTabbed(!pointer.current);
  };

  const onBlur = () => {
    setFocused(false);
    setTabbed(false);
    pointer.current = false;
  };

  const onPointerDown = () => {
    pointer.current = true;
  };

  const handleChange = (ev: React.ChangeEvent<HTMLTextAreaElement>) =>
    onChange(ev.target.value);

  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }

    const timer = setTimeout(() => setStatus(shown ? text : ""), STATUS_DELAY);
    return () => clearTimeout(timer);
  }, [shown, text]);

  return (
    <div
      className={`
        ${css.textarea}
        ${getSizeClasses(size)}
        ${active ? css.active : ""}
        ${tabbed ? css.tabbed : ""}
        ${error ? css.error : ""}
        ${disabled ? css.disabled : ""}
      `}
      onPointerDown={onPointerDown}
    >
      <div className={css.textarea_field}>
        <textarea
          id={fieldId}
          name={name}
          value={value}
          required={required}
          disabled={disabled}
          aria-invalid={error}
          aria-describedby={counter ? descriptionId : undefined}
          onFocus={onFocus}
          onBlur={onBlur}
          onChange={handleChange}
        ></textarea>
        <fieldset className={css.textarea_fieldset}>
          <legend className={css.fieldset_legend}>{label}</legend>
        </fieldset>
        <label htmlFor={fieldId} className={css.textarea_label}>
          {label}
          {required && <span aria-hidden="true">*</span>}
        </label>
      </div>
      {counter && (
        <>
          <p
            className={`${css.textarea_counter} ${shown ? css.shown : ""} ${over ? css.over : ""}`}
            aria-hidden="true"
          >
            {shown ? text : ""}
          </p>
          <p id={descriptionId} className={css.textarea_description}>
            {counter.description(counter.max)}
          </p>
          <p className={css.textarea_status} aria-live="polite">
            {status}
          </p>
        </>
      )}
    </div>
  );
}

Props

PropTypeRequiredDescription
namestringrequiredField name, for forms and browser autofill.
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredMinimum field height and its text size. The user can stretch it vertically. Fixed or responsive.
labelstringrequiredFloating label that rises on focus or when filled.
requiredboolean—Marks the field as required (adds * to the label and the native required).
errorboolean—Visual error state (red border, aria-invalid).
disabledboolean—Disables the field (not editable, reduced opacity).
counter{ max: number; threshold?: number; remaining: (left) => string; exceeded: (over) => string; description: (max) => string }—Turns the counter on whole or not at all: the limit, the percentage it shows from (without it, always), the phrases for what is left and what is over, and the one that tells screen readers the limit. remaining gets what is left, exceeded what is over and description the limit; each returns the whole phrase, singular and plural as the caller writes them. Typing past the limit is allowed and turns the phrase red; the red border is the form's call, through the error prop.
valuestringrequiredField value (controlled).
onChange(value: string) => voidrequiredCallback with the new value on every change.