defuss-shadcn / Forms & Inputs / textarea
TextareaATM
Auto-growing multi-line text input. Built on native <textarea> with field-sizing: content for zero-JS auto-grow - between data-rows and data-max-rows, after which it scrolls. A .textarea-group turns it into a composer: attachment chips, an action row, a drop target - the input of the chat Session.
On this page (9)
§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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | string | — | "" | Live textarea content (input.value), typed or edited from the panel. |
disabled | boolean | true, false | false | Interaction off (native disabled attribute). |
required | boolean | true, false | false | Post-validation marks the field invalid (:user-invalid). |
invalid | boolean | true, false | false | Manual 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