Textarea

How to build a textarea that people can label, write in at length and fix, that grows or resizes without cutting anything off, and that agents can check.

Textareas are where people write more than a line, like a comment, a message, a description or a delivery note. They look like an input that's a few lines taller, which is why they're so often built as one and inherit all of its problems, along with a few of their own. Think of a box too small to see what you've written, a limit that silently cuts off the end of what you pasted, or an Enter key that sends a message you were still writing.

This page walks you through building one the right way. We'll look at:

User Interface (UI)

How a textarea looks, from its parts and sizes to how tall it gets, its count, its states and its tokens.

User Experience (UX)

How a textarea behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.

Developer Experience (DX)

How developers use the Textarea, with the Input's consistent, typed API that works with your forms however you build them.

Agentic Experience (AX)

How agents read, fill in and check a textarea, from its accessibility tree to its contract, its DOM and its tests.

At a glance

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to write in them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Textarea with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Textarea is the box you write in, like the Input is for a single line. Its label, hint and error message are elements of their own, which you link to it by ID, and a Field component, which is on our Roadmap, will put them together for you. Until then, the examples do it with a plain <label>, and the rules hold either way.

<label htmlFor="message">Message</label>
<Textarea id="message" value={value} onChange={(event) => setValue(event.target.value)} />
AspectTextarea
Element<textarea>, inside a <div> that holds its count
Roletextbox, with aria-multiline implied, so screen readers can tell it from an input
NameIts <label>, linked with for, and never its placeholder
KeyboardThe browser's own text editing, with Enter for a new line, which never submits its form
StatesHover, focus, disabled, read-only and invalid
HeightAt least rows lines, resized by people on the block axis, or growing with its text up to maxRows
CountOptional, against a limit, which people can type and paste past
Sizesxs, sm, md, lg and xl, with the same padding and text as the Input
PatternNone in the APG, since a native <textarea> covers it
WCAG 2.21.3.1, 1.4.3, 1.4.4, 1.4.10, 1.4.11, 2.4.7, 2.5.8, 3.3.1, 3.3.2, 4.1.2 and 4.1.3, among others

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A textarea meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Textarea itself, so they're the ones to check when you build or review one. Usage rules are met by the forms that use it, like giving every textarea a label, so they're the ones to check when you review a screen. Both rules need the two to work together, like Enter, which the Textarea leaves to the browser, as long as the page doesn't make it send the form.

The basis says where a rule comes from. About half the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from HTML. The rest are Open Components rules, which are our own, like counting past a limit rather than cutting people off. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level AAA, like the 44px height on touch screens, and a rule that asks for more than its criterion says beyond, like using a native <textarea>, where WCAG would let you rebuild one by hand.

Unit tests run without a layout, so the rules about height, resizing and growing are checked in the browser, at 200% zoom or with visual regression tests.

You'll find the rules for each layer on its page:

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It announces its count with the Button's announce.ts, which every component that announces something shares, so you only need one copy of it, and it reads the same theme tokens, so copy the Button's tokens.css into your theme too.

The tests are in Textarea.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useAttrs, useTemplateRef, watch, type TextareaHTMLAttributes } from "vue";
import { announce, prepareAnnouncer } from "../button/announce";

export interface TextareaProps {
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  /** How many lines it shows. It's never shorter, even when people resize it. */
  rows?: number;
  /** Grows with its text, up to `maxRows`, rather than scrolling. People can't resize it then. */
  autoGrow?: boolean;
  /** How many lines it grows to with `autoGrow`, before it scrolls. Without it, it keeps growing. */
  maxRows?: number;
  /** The most characters it takes. It shows a count, and lets people go over, so nothing they paste gets cut off. */
  limit?: number;
  /** The count's text, for a number of characters left, which is negative over the limit. */
  countLabel?: (remaining: number) => string;
  disabled?: boolean;
}

// Attributes go on the <textarea> rather than on the root, so they're declared below.
defineOptions({ inheritAttrs: false });

const {
  size = "md",
  rows = 3,
  autoGrow = false,
  maxRows,
  limit,
  countLabel = (remaining: number) => {
    const count = Math.abs(remaining);
    const characters = count === 1 ? "character" : "characters";
    return remaining < 0 ? `${count} ${characters} too many` : `${count} ${characters} left`;
  },
  disabled = false,
} = defineProps<TextareaProps>();

/** The value, which `v-model` binds. Without it, the textarea keeps its own, and its form submits it. */
const model = defineModel<string>();

const attrs = useAttrs();
// Classes and styles go on the root, so a class can set its variables. Everything
// else goes on the <textarea>: `id`, `name`, `placeholder`, ARIA and listeners.
// Attrs aren't reactive, so these run on every render.
const onRoot = (key: string) => key === "class" || key === "style";
const rootAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => onRoot(key)));
const controlAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => !onRoot(key)));

const root = useTemplateRef<HTMLElement>("root");
const textarea = useTemplateRef<HTMLTextAreaElement>("textarea");

// What people have typed, so the count works without `v-model` too.
const typed = ref(typeof attrs.value === "string" ? attrs.value : "");
const value = computed(() => model.value ?? typed.value);

// Code points rather than UTF-16 code units, which is how many databases count a
// length limit, like PostgreSQL's varchar(n). An emoji made of several, like a
// family, counts as several.
const remaining = computed(() => (limit === undefined ? 0 : limit - [...value.value].length));
const over = computed(() => remaining.value < 0);
const countText = computed(() => countLabel(remaining.value));
// The count is named after the textarea, so it needs its `id` to describe it.
const countId = computed(() => (limit !== undefined && attrs.id ? `${attrs.id}-count` : undefined));
// Over the limit, it's invalid, whatever the page says.
const invalid = () => (over.value ? "true" : (attrs["aria-invalid"] as TextareaHTMLAttributes["aria-invalid"]));
const describedBy = () => [attrs["aria-describedby"], countId.value].filter(Boolean).join(" ") || undefined;

// `rows` and `maxRows` as variables, in lines, which the styles turn into a minimum
// and a maximum height. Browsers with typed attr() read them from `data-rows` and
// `data-max-rows` before any script runs. The others can't read a number from an
// attribute yet, so they're also set through the CSSOM, which a strict Content
// Security Policy allows, unlike a style attribute. They run after every render,
// since a change to your own `style` replaces the root's.
function setRows() {
  const el = root.value;
  if (!el) return;
  el.style.setProperty("--textarea--rows", String(rows));
  if (autoGrow && maxRows !== undefined) el.style.setProperty("--textarea--max-rows", String(maxRows));
  else el.style.removeProperty("--textarea--max-rows");
}
onMounted(setRows);
onUpdated(setRows);

// Browsers with `field-sizing: content` grow the textarea themselves. In the
// others, it's set to the height of its text, which max-height caps.
function grow() {
  const el = textarea.value;
  if (!autoGrow || !el || (typeof CSS !== "undefined" && CSS.supports?.("field-sizing", "content"))) return;
  el.style.height = "auto";
  el.style.height = `${el.scrollHeight + el.offsetHeight - el.clientHeight}px`;
}
watch(value, () => nextTick(grow));

// Screen readers hear the count once people pause near the limit, rather than on
// every keystroke, which would talk over what they're typing.
const NEAR_LIMIT = 0.8;
const PAUSE = 1000;
let timer: ReturnType<typeof setTimeout> | undefined;
let announced = "";
function announceCount() {
  if (limit === undefined) return;
  clearTimeout(timer);
  timer = setTimeout(() => {
    const near = limit - remaining.value >= limit * NEAR_LIMIT;
    if (!near || countText.value === announced) return;
    announced = countText.value;
    announce(countText.value);
  }, PAUSE);
}

// The template shows the model, or the `value` attribute while there's none, so a
// textarea without `v-model`, like a read-only one, can still start with a value.
function onInput(event: Event) {
  const text = (event.target as HTMLTextAreaElement).value;
  typed.value = text;
  model.value = text;
  announceCount();
}

// A form reset puts back the textarea's first value without an input event, so
// the count reads it again once the reset is done. That's after the event and,
// for a click on a reset button, after the microtasks that follow it too.
let form: HTMLFormElement | null = null;
function onReset() {
  setTimeout(() => {
    const text = textarea.value?.value ?? "";
    typed.value = text;
    if (model.value !== undefined) model.value = text;
  });
}

// A ref to the Textarea gets you the <textarea>, through `textarea`.
defineExpose({ textarea });

onMounted(() => {
  // The live region has to be in the page before anything is announced.
  if (limit !== undefined) prepareAnnouncer();
  grow();
  form = textarea.value?.form ?? null;
  form?.addEventListener("reset", onReset);

  if (import.meta.env.DEV) {
    const el = textarea.value;
    const named = el?.labels?.length || el?.hasAttribute("aria-label") || el?.hasAttribute("aria-labelledby");
    if (el && !named) {
      console.warn("[Textarea] has no accessible name: give it a <label for>.", el);
    }
    if (el && limit !== undefined && !el.id) {
      console.warn("[Textarea] has a limit but no id: give it one, so its count can describe it.", el);
    }
  }
});

onBeforeUnmount(() => {
  clearTimeout(timer);
  form?.removeEventListener("reset", onReset);
});
</script>

<template>
  <div
    ref="root"
    class="textarea"
    v-bind="rootAttrs()"
    :data-size="size"
    :data-rows="rows"
    :data-auto-grow="autoGrow ? '' : undefined"
    :data-max-rows="autoGrow ? maxRows : undefined"
    :data-limit="limit"
  >
    <textarea
      ref="textarea"
      data-slot="control"
      :rows="rows"
      :disabled="disabled"
      v-bind="controlAttrs()"
      :value="model ?? ($attrs.value as string | undefined)"
      :aria-invalid="invalid()"
      :aria-describedby="describedBy()"
      @input="onInput"
    />
    <span v-if="limit !== undefined" data-slot="count" :id="countId" :data-over-limit="over ? '' : undefined">
      {{ countText }}
    </span>
  </div>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The textarea's styles go in `components`,
   so they win over the resets in `base`, and give way to utilities and to any
   style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

@layer components {
  .textarea {
    /* Set per size. The Input's values, so a textarea of one row is as tall as an
       input, and the two line up in a form. */
    --textarea--height: 2.25rem;
    --textarea--padding: 0.75rem;
    --textarea--font-size: 0.875rem;
    --textarea--radius: 0.5rem;
    /* The text colour at 55%, which has 3:1 against the page, so people can see where to type. */
    --textarea--border-color: color-mix(in srgb, currentColor 55%, transparent);
    /* The text colour at 70%, which has 4.5:1 against the page, like the placeholder. */
    --textarea--count--color: color-mix(in srgb, currentColor 70%, transparent);
    /* What's left of one row's height once its line of text is in, above and below it.
       `lh` is the textarea's line height, so it follows larger text and wider lines. */
    --textarea--padding-block: max(0.125rem, (var(--textarea--height) - 1lh) / 2 - 1px);
    /* `rows` lines, with their padding and border. Resizing and growing stop there.
       The rows come from `data-rows`, in browsers with typed attr(), so it holds before
       the script runs. In the others, it has no value until then, and the `rows`
       attribute sets the height, except with auto-grow, whose field-sizing ignores
       `rows`, so it starts at the height of its text, with no maximum. */
    --textarea--min-height: calc(
      var(--textarea--rows, attr(data-rows type(<number>))) * 1lh + 2 * var(--textarea--padding-block) + 2px
    );
    /* Only set with `maxRows`. Without it, max-height has no value, which is none. */
    --textarea--max-height: calc(
      var(--textarea--max-rows, attr(data-max-rows type(<number>))) * 1lh + 2 * var(--textarea--padding-block) + 2px
    );

    display: flex;
    flex-direction: column;
    /* In a flex row, it can get narrower than a <textarea> is on its own, rather than overflow. */
    min-width: 0;
    color: var(--color--neutral-text);
    font-family: inherit;
    font-size: var(--textarea--font-size);
  }

  /* Sizes */
  .textarea[data-size="xs"] {
    --textarea--height: 1.5rem;
    --textarea--padding: 0.5rem;
    --textarea--font-size: 0.75rem;
    --textarea--radius: 0.375rem;
  }
  .textarea[data-size="sm"] {
    --textarea--height: 2rem;
    --textarea--padding: 0.625rem;
    --textarea--radius: 0.375rem;
  }
  .textarea[data-size="lg"] {
    --textarea--height: 2.5rem;
    --textarea--padding: 0.875rem;
    --textarea--font-size: 1rem;
  }
  .textarea[data-size="xl"] {
    --textarea--height: 3rem;
    --textarea--padding: 1rem;
    --textarea--font-size: 1rem;
    --textarea--radius: 0.75rem;
  }

  /* Parts. The control draws the field itself, since its resize handle and its
     scrollbar belong to it. */
  .textarea > [data-slot="control"] {
    display: block;
    box-sizing: border-box;
    width: 100%;
    /* min-height rather than height, so it's never shorter than its rows, and grows with larger text. */
    min-height: var(--textarea--min-height);
    max-height: var(--textarea--max-height);
    margin: 0;
    padding: var(--textarea--padding-block) var(--textarea--padding);
    border: 1px solid var(--textarea--border-color);
    border-radius: var(--textarea--radius);
    background-color: transparent;
    color: inherit;
    font: inherit;
    /* Taller than the Input's, since people read their text back over several lines. */
    line-height: 1.5;
    letter-spacing: inherit;
    /* Only taller or shorter, so it never outgrows its column. `block` follows the
       writing mode, and `vertical` is for browsers that don't know it. */
    resize: vertical;
    resize: block;
  }
  .textarea > [data-slot="control"]::placeholder {
    /* The text colour at 70%, which has 4.5:1 against the page. Firefox lowers its opacity on its own. */
    color: color-mix(in srgb, currentColor 70%, transparent);
    opacity: 1;
  }
  /* It grows with its text, so there's nothing for people to resize. Browsers without
     field-sizing get their height from the script instead. */
  .textarea[data-auto-grow] > [data-slot="control"] {
    resize: none;
    field-sizing: content;
  }
  .textarea > [data-slot="count"] {
    margin-block-start: 0.375rem;
    color: var(--textarea--count--color);
    line-height: 1.25;
    text-align: end;
    /* So the count doesn't jiggle as its digits change. */
    font-variant-numeric: tabular-nums;
  }
  .textarea > [data-slot="count"][data-over-limit] {
    color: var(--color--error-text);
  }

  /* States. Each one reads the attribute on the <textarea> that exposes it, so what
     people see and what assistive technologies report can't drift apart. :invalid
     isn't one of them: it matches a required textarea before anyone has typed in it.
     The variables are set on the root, as every other one is, so a class of yours
     still sets them in every state. Hover is in :where(), so invalid always wins. */
  @media (hover: hover) {
    .textarea:where(:has(> [data-slot="control"]:hover:not(:disabled, [readonly]))) {
      --textarea--border-color: color-mix(in srgb, currentColor 80%, transparent);
    }
  }
  .textarea > [data-slot="control"]:focus-visible {
    outline: 2px solid var(--color--focus);
    outline-offset: 2px;
  }
  .textarea > [data-slot="control"][readonly] {
    /* Dashed, so it reads as something you can't change, while the text keeps its contrast. */
    border-style: dashed;
    cursor: default;
  }
  .textarea:has(> [data-slot="control"][aria-invalid="true"]) {
    --textarea--border-color: var(--color--error-text);
  }
  .textarea > [data-slot="control"][aria-invalid="true"] {
    /* A second pixel of border, drawn inside, so the field doesn't grow. */
    box-shadow: inset 0 0 0 1px var(--textarea--border-color);
  }
  .textarea:has(> [data-slot="control"]:disabled) {
    opacity: 0.5;
  }
  .textarea > [data-slot="control"]:disabled {
    cursor: not-allowed;
    /* Nobody can write in it, so there's nothing to make room for. */
    resize: none;
  }

  /* On touch screens, the text is at least 16px, or Safari on iOS zooms in when the
     textarea gets focus, and the field is at least 44px tall, whatever its rows. */
  @media (any-pointer: coarse) {
    .textarea {
      font-size: max(var(--textarea--font-size), 1rem);
    }
    .textarea > [data-slot="control"] {
      min-height: max(var(--textarea--min-height), 2.75rem);
    }
  }

  /* With more contrast, the border is drawn in the text colour at full strength. */
  @media (prefers-contrast: more) {
    .textarea {
      --textarea--border-color: currentColor;
    }
  }

  /* Forced colors mode keeps borders but drops shadows, so mark states with system colours. */
  @media (forced-colors: active) {
    .textarea > [data-slot="control"]:focus-visible {
      outline-color: Highlight;
    }
    .textarea:has(> [data-slot="control"]:disabled) {
      color: GrayText;
      opacity: 1;
    }
    .textarea > [data-slot="control"]:disabled {
      border-color: GrayText;
    }
  }
}
</style>

Sources

Copyright © 2026