Textarea
.textarea is declared in the same rule as
.input and .select
— same border, radius, shadow, focus ring and 16px-on-touch — and then adds three
declarations of its own. The interesting one is
field-sizing: content, which makes it grow as the reader types, with no
JavaScript.
When to use it
Put it on a real <textarea>, wrapped in a
.field with a label. Use it whenever the answer
might run to more than one line: a note, a description, a message, an address.
Visible to everyone on the project.
<div class="field" style="max-inline-size:26rem">
<label class="label" for="ta-note">Note</label>
<textarea class="textarea" id="ta-note" placeholder="Type a few lines and watch it grow"></textarea>
<p class="help">Visible to everyone on the project.</p>
</div>
<div class="field" style="max-inline-size:26rem">
<label class="label" for="ta-note">Note</label>
<textarea class="textarea" id="ta-note" placeholder="Type a few lines and watch it grow"></textarea>
<p class="help">Visible to everyone on the project.</p>
</div>
The three declarations
field-sizing: content
The browser sizes the control to its content instead of to the
rows attribute. Every implementation of "auto-growing textarea" before
this was JavaScript measuring a hidden clone on every keystroke; this is one
declaration and it costs nothing.
It is not in every browser yet. Where it is missing the textarea is simply its
minimum height and scrolls — the ordinary behaviour. The feature degrades to the
default rather than breaking, which is why Deck ships it unguarded rather than
behind an @supports.
A two-row floor
min-block-size: calc(var(--control-h) * 2) — twice the height of an
input. An empty auto-sizing textarea would otherwise be one row tall and
indistinguishable from a text input, which is the one thing a textarea has to
communicate before it is used.
A 22rem ceiling
max-block-size: 22rem. Without it, a textarea with a thousand words in
it grows to a thousand words tall and the reader loses both the rest of the form
and the submit button. At the ceiling it starts scrolling, which is the ordinary
behaviour again.
Vertical resize stays available
resize: vertical rather than none or both.
Vertical because a reader who wants more room should have it — the ceiling is
Deck's guess, not theirs. Not horizontal, because a textarea wider than its
.field breaks the form layout around it.
<div class="field" style="max-inline-size:26rem">
<label class="label" for="ta-long">Release notes</label>
<textarea class="textarea" id="ta-long">Deck 0.1.0
The first tagged build. One stylesheet, no build step, no runtime dependencies.
Paste enough text in here and the box stops growing at 22rem and starts scrolling. Drag the corner if you want more room than that.</textarea>
</div>
<div class="field" style="max-inline-size:26rem">
<label class="label" for="ta-long">Release notes</label>
<textarea class="textarea" id="ta-long">Deck 0.1.0
The first tagged build. One stylesheet, no build step, no runtime dependencies.
Paste enough text in here and the box stops growing at 22rem and starts scrolling. Drag the corner if you want more room than that.</textarea>
</div>
States
Every state comes from the shared rule, so a textarea hovers, focuses, disables and
validates exactly as an input does. :user-invalid rather than
:invalid means an empty required textarea is not marked wrong before the
reader has touched it — the reasoning is on the
input page.
<div class="stack stack-3" style="max-inline-size:26rem">
<textarea class="textarea" aria-label="Ordinary">Ordinary</textarea>
<textarea class="textarea" readonly aria-label="Readonly">Readonly</textarea>
<textarea class="textarea" disabled aria-label="Disabled">Disabled</textarea>
</div>
<div class="stack stack-3" style="max-inline-size:26rem">
<textarea class="textarea" aria-label="Ordinary">Ordinary</textarea>
<textarea class="textarea" readonly aria-label="Readonly">Readonly</textarea>
<textarea class="textarea" disabled aria-label="Disabled">Disabled</textarea>
</div>
Counting characters
Deck ships no character counter for .textarea. The
.editor component has one — .editor-count, with an
.is-over state driven by data-limit — but that is a rich
text editor rather than a plain control.
For a plain textarea, an <output> beside it is the accessible
version, because <output> is an implicit live region and the
remaining count is announced as it changes.
<div class="field">
<label class="label" for="bio">Bio</label>
<textarea class="textarea" id="bio" maxlength="280"></textarea>
<output class="help" for="bio">280 left</output>
</div>
Classes
.textarea is declared alongside .input and
.select in one shared rule, so the extractor does not treat it as a
component root and there is no completeness check on this page.
| Class | Layer | What it does | Source |
|---|---|---|---|
.textarea |
deck.components |
Grows with its content instead of scrolling in a 4-row box | 06-forms.css:49 |
Tokens
--control-h is doing double duty here: the same token that sets an
input's height sets a textarea's minimum, so the two line up in a form.
| Token | Live | Declared value |
|---|---|---|
--control-h |
42px |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--bg-sunken |
light-dark(var(--ink-100), oklch(8.6% .012 var(--hue-neutral))) |
|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--focus |
light-dark(var(--brand-500), var(--brand-300)) |
|
--ring |
0 0 0 3px light-dark(oklch(from var(--focus) l c h / .32), oklch(from var(--focus) l c h / .42)) |
|
--r-sm |
7px |
|
--shadow-1 |
0 1px 2px oklch(20% .03 var(--shadow-color) / .06), 0 1px 1px oklch(20% .03 var(--shadow-color) / .04) |
|
--text-base |
.9375rem |
|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--bad-500 |
oklch(58% calc(var(--chroma-status) * 1.25) var(--hue-bad)) |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--dur-1 |
110ms |
Accessibility
-
It needs a label like any other control. A
<label class="label" for="id">is the right answer;aria-labelis the fallback the short examples on this page use. - Growing as you type is announced as nothing, which is correct — it is a visual convenience. But it does move everything below the textarea down the page while the reader is typing, and for a reader using a screen magnifier that can be disorienting. The 22rem ceiling bounds how bad that gets.
- 16px on touch is the same iOS fix as on an input. Below 16px, Safari zooms the page when the control takes focus, and a page that zooms unexpectedly loses a low-vision reader's place.
-
resize: verticalis a genuine accessibility feature. Removing the resize handle takes away a reader's ability to see more of their own text at once. Deck constrains the axis rather than removing the affordance. -
A
maxlengthwith no visible counter is a trap. The control silently stops accepting input, which reads as a broken keyboard. If you set a limit, show the remaining count — and put it in an<output>so it is announced. -
Placeholder text is not a label and is deliberately
--text-faint, which is below 4.5:1. It is a hint, and Deck styles it as one.
Right to left
Padding is logical and the browser handles the text direction itself, so a textarea mirrors with no extra rules. The resize handle moves to the opposite corner on its own, because the browser draws it at the end edge.
<div dir="rtl" class="field" style="max-inline-size:26rem">
<label class="label" for="ta-rtl">ملاحظة</label>
<textarea class="textarea" id="ta-rtl" placeholder="اكتب هنا"></textarea>
</div>
<div dir="rtl" class="field" style="max-inline-size:26rem">
<label class="label" for="ta-rtl">ملاحظة</label>
<textarea class="textarea" id="ta-rtl" placeholder="اكتب هنا"></textarea>
</div>
Reduced motion
The border, shadow and background transition over
110ms, and the global reset collapses
all three to .01ms under
prefers-reduced-motion: reduce.
The growth from field-sizing is not a transition — the box jumps to its
new height on each new line. That is deliberate: an animated height on every
keystroke would be motion nobody asked for, and it would lag the caret.
Printing
src/99-print.css gives every control a plain #999 border
on white and drops the minimum height, so a printed form reads as a filled-in
document rather than a screenshot of a web page.
.textarea then gets a line of its own:
.textarea { max-block-size: none !important; block-size: auto !important; }
Both halves matter. Clearing max-block-size undoes the
22rem ceiling, and block-size: auto lets the box take its
content's height — so a long note prints in full instead of printing the first
22rem of itself with no indication that anything was cut.
Overriding it
/* One textarea, no layer needed */
<textarea class="textarea" style="max-block-size:40rem"></textarea>
@layer app.components {
/* A taller floor for a control that is mostly used for long text */
.textarea { min-block-size: calc(var(--control-h) * 4); }
/* Turn off the growth and go back to rows */
.textarea-fixed { field-sizing: fixed; }
}
field-sizing: fixed is the explicit way back to the old behaviour, and
it makes the rows attribute meaningful again.
When not to use it
-
Not for a single line. Use
.input. A textarea accepts newlines, which means a name field can contain one and your validation now has to care. -
Not for formatted text. A textarea holds plain text. If the reader
needs bold, lists or links, use
.editor, which is acontenteditableregion with a toolbar and a hidden input that carries the HTML. -
Not for code. A textarea has no monospace font, no tab handling
and no line numbers. Add
.monoat minimum, and consider whether an editor component is what you actually need. -
Not for a value with a known shape. A date, a phone number or a
quantity has a control of its own —
.datefield,.phone,.number— each of which gives a phone the right keypad and validates without you writing anything. -
Not without a ceiling, if you raise it. Removing
max-block-sizelets a long value push the submit button below the fold indefinitely. If you raise it, raise it to a number rather than tonone.