Theme
On this page (9)
Component Skill — components/textarea/component-skill.md

Native basis

<textarea> element with field-sizing: content for auto-growing.

Web Platform APIs

<textarea>field-sizing: contentlhDrag and Drop APIURL.createObjectURL()

Classes

.textarea.textarea-group.textarea-group-actions.textarea-group-spacer.textarea-group-count

Notes

• Uses field-sizing: content for auto-growing - no JavaScript needed.

• Shares the same border, focus, disabled, and invalid styles as .input.

• 5rem tall by default; data-rows (1-10) sets where it starts, data-max-rows (2-12) where it stops growing and scrolls; data-resize="none" drops the handle.

§Default

Auto-growing textarea with placeholder.

§Disabled

Non-interactive textarea.

§With description

Textarea with label and helper text.

§Rows and a limit

data-rows='2' is where it starts, data-max-rows='6' where it stops: in between it grows line by line with its text (field-sizing: content), past six lines it scrolls. Row sizes are in lh - true lines of this textarea at any font size.

§Character count

maxlength stops the typing natively; an <output> tied to the field (for=) shows the count, updated through df$ on every input - and turns destructive near the limit.

§Composer

A .textarea-group: the text grows from one line to six, + attaches images (the native picker), and so does dropping them anywhere on the composer (the Drag and Drop API - it lights up while you drag). Chips reuse the file input's .file-drop-item; ✕ removes one. Enter sends, Shift+Enter adds a line, and send stays disabled while there is nothing to send.

§Inline composer

data-layout='inline': attach before the text, send after it, on one row and bottom-aligned - the field grows upward as it wraps, the chat input shape. The same script drives it.

§States

Machine contract - verified against textarea.schema.json by bun run verify:

StateTypeValuesDefaultDescription
valuestring—""Live textarea content (input.value), typed or edited from the panel.
disabledbooleantrue, falsefalseInteraction off (native disabled attribute).
requiredbooleantrue, falsefalsePost-validation marks the field invalid (:user-invalid).
invalidbooleantrue, falsefalseManual invalid flag (aria-invalid="true").

§CSS view file

/* -- Textarea component ----------------------------------------- */
@layer components {
  .textarea {
    /* border-box: UA gives <textarea> content-box, which would render the
       min-height 2px above the shared field frame (see .input) */
    box-sizing: border-box;
    width: 100%;
    min-height: 5rem;
    border: 1px solid var(--input);
    border-radius: var(--radius-md);
    background: var(--background);
    padding: 0.5rem 0.75rem;
    font-size: 0.875rem;
    font-family: var(--font-sans);
    color: var(--foreground);
    outline: none;
    box-shadow: var(--shadow-xs);
    transition: border-color 150ms, box-shadow 150ms;
    resize: vertical;
    field-sizing: content;
    line-height: 1.5;
    /* -- Rows: data-rows is the height it starts at, data-max-rows where it
       stops growing and scrolls instead (field-sizing grows it in between).
       1lh = one line of this textarea, + the padding and border. -------- */
    &[data-rows="1"] { min-height: calc(1lh + 1rem + 2px); }
    &[data-rows="2"] { min-height: calc(2lh + 1rem + 2px); }
    &[data-rows="3"] { min-height: calc(3lh + 1rem + 2px); }
    &[data-rows="4"] { min-height: calc(4lh + 1rem + 2px); }
    &[data-rows="5"] { min-height: calc(5lh + 1rem + 2px); }
    &[data-rows="6"] { min-height: calc(6lh + 1rem + 2px); }
    &[data-rows="8"] { min-height: calc(8lh + 1rem + 2px); }
    &[data-rows="10"] { min-height: calc(10lh + 1rem + 2px); }
    &[data-max-rows="2"] { max-height: calc(2lh + 1rem + 2px); }
    &[data-max-rows="3"] { max-height: calc(3lh + 1rem + 2px); }
    &[data-max-rows="4"] { max-height: calc(4lh + 1rem + 2px); }
    &[data-max-rows="5"] { max-height: calc(5lh + 1rem + 2px); }
    &[data-max-rows="6"] { max-height: calc(6lh + 1rem + 2px); }
    &[data-max-rows="8"] { max-height: calc(8lh + 1rem + 2px); }
    &[data-max-rows="10"] { max-height: calc(10lh + 1rem + 2px); }
    &[data-max-rows="12"] { max-height: calc(12lh + 1rem + 2px); }
    &[data-resize="none"] { resize: none; }
    &:focus {
      border-color: var(--ring);
      box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2);
    }
    &::placeholder { color: var(--muted-foreground); }
    /* Disabled = legible but inert: a muted surface and muted text inside
       the full-strength border - never an opacity fade that lets the box
       dissolve into the page. */
    &:disabled {
      background-color: var(--muted);
      color: var(--muted-foreground);
      box-shadow: none;
      cursor: not-allowed;
      /* resize stays off (Chrome would still let a disabled field be dragged
         taller), but the corner keeps a grip so it still reads as a text
         field - two painted diagonal strokes where the native grip sits */
      resize: none;
      background-image:
        linear-gradient(135deg, transparent 0 46%, var(--muted-foreground) 46% 54%, transparent 54%),
        linear-gradient(135deg, transparent 0 46%, var(--muted-foreground) 46% 54%, transparent 54%);
      background-repeat: no-repeat;
      background-size: 0.625rem 0.625rem, 0.3125rem 0.3125rem;
      background-position: right 0.1875rem bottom 0.1875rem, right 0.1875rem bottom 0.1875rem;
    }
    &:is([aria-invalid="true"], :user-invalid:is(form *):not(form[data-validate="submit"]:not([data-submitted]) *)) {
      border-color: var(--destructive);
      &:focus {
        border-color: var(--destructive);
        box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2);
      }
    }
  }
  /* -- Textarea group: a composer --------------------------------
     The GROUP draws the frame (border, radius, focus ring, drop target);
     the .textarea inside goes frameless and grows. Children in order: an
     optional .file-drop-list (attachment chips, the file-input's own
     items), the textarea, a .textarea-group-actions row (attach, send,
     a counter). data-layout="inline" puts the actions beside the text -
     a one-line chat composer that grows upward. */
  .textarea-group {
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    width: 100%;
    border: 1px solid var(--input);
    border-radius: var(--radius-lg);
    background: var(--background);
    box-shadow: var(--shadow-xs);
    transition: border-color 150ms, box-shadow 150ms, background-color 150ms;
    & > .textarea {
      min-height: calc(2lh + 1rem);
      border: 0;
      border-radius: inherit;
      background: transparent;
      box-shadow: none;
      resize: none;
      padding: 0.75rem 0.875rem 0.25rem;
      &:focus { box-shadow: none; }
    }
    &:has(> .textarea:focus) {
      border-color: var(--ring);
      box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2);
    }
    &:has(> .textarea:disabled) {
      background: var(--muted);
      box-shadow: none;
    }
    /* attachment chips above the text - a wrapping row of file items */
    & > .file-drop-list {
      display: flex;
      flex-wrap: wrap;
      gap: 0.5rem;
      padding: 0.625rem 0.625rem 0;
      &:empty { display: none; }
      & > .file-drop-item { width: 13rem; max-width: 100%; box-sizing: border-box; }
    }
    & > .textarea-group-actions {
      display: flex;
      align-items: center;
      gap: 0.25rem;
      padding: 0.375rem;
      /* everything after the spacer sits at the end - usually send */
      & > .textarea-group-spacer { flex: 1; }
    }
    & .textarea-group-count {
      padding-inline: 0.375rem;
      color: var(--muted-foreground);
      font-size: 0.75rem;
      font-variant-numeric: tabular-nums;
    }
    /* inline: [actions] text [actions] on one row, aligned to the last line */
    &[data-layout="inline"] {
      flex-flow: row wrap;
      align-items: flex-end;
      & > .file-drop-list { flex-basis: 100%; }
      & > .textarea {
        flex: 1;
        min-width: 0;
        min-height: calc(1lh + 1rem);
        padding: 0.5rem 0.25rem;
      }
      & > .textarea:first-child { padding-inline-start: 0.875rem; }
      & > .textarea-group-actions { padding: 0.3125rem; }
    }
    /* a file dragged over the composer: the page sets data-dragover */
    &[data-dragover] {
      border-style: dashed;
      border-color: var(--ring);
      background: color-mix(in oklch, var(--accent) 45%, var(--background));
    }
  }
  /* -- Accessibility ---------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .textarea,
    .textarea-group {
      transition: none;
    }
  }
  @media (prefers-contrast: more) {
    .textarea,
    .textarea-group {
      border-width: 2px;
    }
    .textarea-group > .textarea { border-width: 0; }
    .textarea:disabled {
      border-color: var(--muted-foreground);
    }
  }
  @media (forced-colors: active) {
    .textarea-group { border-color: ButtonBorder; }
    .textarea-group:has(> .textarea:focus) { outline: 2px solid Highlight; outline-offset: 1px; }
    .textarea-group > .textarea:focus { outline: none; }
    .textarea {
      border-color: ButtonBorder;
      color: ButtonText;
      background: Field;
    }
    .textarea:focus {
      outline: 2px solid Highlight;
      outline-offset: 1px;
    }
    .textarea:disabled {
      opacity: 1;
      background-image: none;
      color: GrayText;
      border-color: GrayText;
    }
    .textarea:is([aria-invalid="true"], :user-invalid:is(form *):not(form[data-validate="submit"]:not([data-submitted]) *)) {
      border-color: LinkText;
    }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub