CSS field-sizing for Auto-Growing Textareas
Build a bounded auto-growing textarea with CSS field-sizing, persistent form semantics, hostile-content fixtures, and a value-preserving fallback.
CSS field-sizing can make a textarea grow with its value, but the production pattern needs explicit minimum and maximum heights, stable semantics, and a narrow fallback. This tutorial treats auto-growing textarea CSS as a growth contract, tests the field-sizing content path against hostile values and 200% zoom, and keeps textarea autosize from moving or rewriting the user’s text.
Start with the growth contract
An auto-growing field needs a product contract before it needs CSS. Mine is: begin at a useful minimum, grow with entered content without changing the available inline width, stop at a deliberate maximum, then scroll inside the control. The label, help, error, focus ring, and submitted value must remain stable through every size.
That contract keeps CSS field-sizing from becoming a one-line demo that collapses under real copy. I define a 3-line minimum for orientation and a 12-line maximum for page stability. Those are product choices, not browser defaults. A support note may need six lines; a chat composer may only deserve four; a long-form editor should probably be a fixed workspace instead.
The control also needs a width owner. I use inline-size: 100%, max-inline-size: 100%, and box-sizing: border-box inside a bounded container. Long tokens get overflow-wrap: anywhere. Without that frame, content-sized form controls can surprise the surrounding layout before height even becomes the problem. CSS field-sizing needs these inline rails as much as it needs block-size limits.
This is consistent with my broader view of forms that respect the user’s time: preserve work and make correction cheap. Growth is valuable only when it keeps nearby context visible. If every pasted paragraph pushes the submit action out of reach, the field technically autosizes while the form becomes harder to operate.
- Minimum
- Explicit three-line block size; rows is not treated as the effective minimum.
- Growth
- Content determines preferred block size inside a fixed inline-size.
- Cap
- Explicit twelve-line maximum.
- After cap
- Overflow scrolls inside the textarea while page landmarks stay stable.
What CSS field-sizing actually changes
The default value of field-sizing is fixed. With field-sizing: content, a supported form control uses its content for preferred sizing. For a textarea with constrained width, additional wrapped lines can grow its block size until another CSS limit takes over.
The current CSS Forms editor’s draft defines the property and explicitly labels the document as work in progress. That status belongs in the implementation note. Feature detection can choose a branch today; it cannot promise tomorrow’s draft wording or identical geometry across engines.
CSS field-sizing also changes how I think about rows and cols. MDN notes that those attributes affect the default preferred size and no longer provide the effective dimensions under content sizing. I do not rely on rows="3" as the minimum. I write an explicit min-block-size derived from line height, padding, and borders.
Placeholder content participates in preferred size, which is another reason to bound the control instead of letting a long instructional sentence decide the first render. The HTML rendering guidance for textareas also reminds me that wrapping behavior is part of the platform surface. The property changes sizing; it does not replace the textarea’s value, selection, wrapping, validation, or accessible name.
Bound width and height
My production CSS makes the envelope visible in code:
.growing-field {
box-sizing: border-box;
inline-size: 100%;
max-inline-size: 100%;
min-block-size: calc(3lh + 1.5rem);
max-block-size: calc(12lh + 1.5rem);
overflow-x: hidden;
overflow-y: auto;
overflow-wrap: anywhere;
resize: vertical;
}
@supports (field-sizing: content) {
.growing-field { field-sizing: content; }
}
The padding allowance depends on the component tokens, so I would express it through custom properties in a design system. The important part is that the minimum and cap are inspectable. CSS field-sizing controls the preferred size inside those rails; it does not own the entire layout.
At the cap, internal scrolling is a feature. It preserves page landmarks, sticky actions, and sibling content. I keep the scrollbar available instead of hiding it, because it signals that more value exists. The control should retain a visible focus ring that is not clipped by an overflow wrapper.
Manual resize is a separate policy. resize: vertical lets a user ask for more room, but browser interaction can vary and a user-resized height may outlive content changes in surprising ways. I test it rather than claiming the CSS contract eliminates every native difference. In a dense composer I may disable manual resize; in a notes form I usually keep it.
Keep label, help, error, and focus intact
A growing rectangle is still a form control. I use a visible <label for>, persistent help text, and an error element connected through aria-describedby. When the field becomes invalid, I append the error identifier rather than replacing the help relationship. Size changes never move focus to a wrapper or mirror.
The placeholder is an example, not a label. It can disappear, be clipped, or be mistaken for entered content. The accessible name must survive an empty value, a long value, and the error state. Accessible names and descriptions is the deeper contract I reuse here.
I also avoid announcing every growth step. Typing already produces input feedback; turning each new line into a live-region message adds noise. Announce validation or an important limit, not geometry. The character or line policy should be visible before the user reaches it.
Focus is tested at minimum height, maximum height, and 200% zoom. The ring must remain visible, the caret must scroll into view inside the control, and the page must not jump sideways when a scrollbar appears. Browser focus foundations are relevant because a fallback that swaps or clones the textarea can accidentally destroy selection and focus identity.
The safest pattern uses one actual textarea in both branches. CSS and a small sizing adapter can change its box. The form value, label association, validation, selection, undo stack, and submission semantics stay owned by the native control.
Add a narrow fallback
The fallback should imitate the growth contract, not reimplement a textarea. Under @supports not (field-sizing: content), I attach one input listener that resets the inline height to auto, reads scrollHeight, clamps it between computed minimum and maximum pixels, then applies that height. Overflow becomes auto only at the cap.
Reset-before-read matters. Without it, deleting content can leave the previous larger inline height as the measurement frame, so the control grows but does not shrink. The fallback never copies the value into a hidden mirror and never changes the value to measure. That removes a class of escaping, whitespace, and accessibility bugs.
Programmatic changes are the sharp edge. Setting textarea.value does not dispatch an input event. After draft restore, reset, autofill adapter, or application state update, the owner must call the same syncSize(textarea) function. A ResizeObserver cannot infer every value change; it observes the result, not the cause.
I expose that resync in the lab and test it explicitly. Native CSS field-sizing reacts through layout. The JavaScript fallback needs an application hook. That difference stays in the receipt instead of being hidden behind a “supported” badge. Keeping that ownership narrow makes CSS field-sizing progressive instead of turning it into a second form-control implementation.
The fallback is intentionally small: input, reset, and explicit programmatic sync. If a component needs a mutation observer, mirror tree, line parser, and animation coordinator, I would reconsider whether auto-growth is worth that ownership.
| Branch | Owner | Required resync |
|---|---|---|
| Native | CSS field-sizing and layout | Browser layout reacts to value |
| Fallback | Input/reset listener plus bounded scrollHeight | Explicit call after programmatic value changes |
- Both branches preserve one textarea, label, described-by references, value, selection, minimum, cap, and overflow policy.
Test hostile content and zoom
I do not test textarea autosize with one friendly sentence. The fixture set starts empty, then uses placeholder-only, wrapped prose, one long unbroken token, right-to-left text, a 30-line paste, an error message, form reset, and a programmatic restore. Each case records the value digest, block size, scroll height, overflow state, accessible references, and viewport width.
The 200% zoom pass matters because fewer characters fit on each line. A six-line desktop value can become a capped editor without changing its characters. The page must retain horizontal containment, and the textarea must show its own vertical scroll when necessary. Responsive layouts need content fixtures gives this test more value than a screenshot at one width.
Direction is content, not decoration. RTL text should preserve the field’s reading direction and still keep the resize handle, scrollbar, error, and help understandable. Long tokens should wrap instead of widening the form. Pasted content should reach the cap in one update without flashing an unbounded intermediate height.
The lab reports the active native or fallback branch, but that is not an accessibility verdict. Parsing field-sizing: content says the browser accepts the property. It does not prove assistive-technology behavior, manual-resize quality, or identical metrics across fonts. I still run keyboard, zoom, reset, focus, and browser checks on the actual component before calling it production-ready.
| State | Expected invariant |
|---|---|
| Empty / placeholder | Explicit minimum; visible label remains the name |
| Typing / paste | Value preserved; growth reaches cap; no page horizontal overflow |
| Error | Help and error both remain described |
| Reset | Value and size return together |
| 200% zoom | More wrapping is allowed; focus and internal scroll stay usable |
Know when a fixed editor is better
Auto-growth is useful when the field is part of a form and nearby context remains important: support notes, short descriptions, comments, addresses, and compact prompts. It reduces premature internal scrolling while the value is small.
A fixed editor is better when the task is the document. Long markdown, code, transcripts, structured templates, and side-by-side review need stable tools, persistent actions, predictable viewport space, and often line navigation. Letting those surfaces consume the page as content grows makes every surrounding control move.
I also avoid content-sized form controls inside virtualization or highly animated layouts unless I have measured the cost. Every input can trigger layout and move content below. One field is usually trivial; dozens of expanding rows can create a page that feels unstable even if its performance metrics pass.
The right question is not whether CSS field-sizing is elegant. It is whether changing height helps the user maintain context for this task. A bounded textarea answers yes for a narrow band of content. The maximum is part of the interface hierarchy: after this point, the field behaves like an editor with its own viewport.
That framing keeps the component modest. It does not become a universal textarea replacement or a reason to erase native affordances. It is one progressive enhancement, with a fixed minimum, explicit cap, honest scroll state, and a small escape hatch for browsers that need the fallback.
Copy the production pattern
The production pattern is one semantic textarea and two sizing paths. The HTML owns label, help, error, value, and submission. Shared CSS owns inline width, minimum, maximum, wrapping, overflow, focus, and resize policy. CSS field-sizing owns content-based preferred height when supported. A narrow JavaScript adapter owns scrollHeight clamping elsewhere.
I ship it with a receipt: active branch, CSS support result, computed minimum and maximum, each hostile fixture’s measured height and overflow state, label and aria-describedby targets, value digest before and after sizing, page overflow check, and zoom viewport. The receipt should fail closed if a fixture is too large or a required element is missing.
The MDN field-sizing reference is the practical compatibility bookmark, but I still pin the article’s updated date and revisit when the CSS Forms draft or Baseline status moves. This pattern lives near an unstable platform edge; maintenance is part of using it responsibly.
The final review is visual and behavioral. Does empty feel intentional? Does growth feel calm? Does the cap preserve the form? Can the user still understand label, help, error, and next action? If those answers hold in native and fallback paths, the one-line property has become a product component instead of a demo.
Runnable browser artifact — Measured geometry and semantic invariants in executing browsers; not identical layout or assistive-technology behavior everywhere.
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CSS field-sizing textarea lab</title><style>:root{font-family:system-ui;color-scheme:light dark;background:#f7f4ea;color:#17332a}body{max-width:64rem;margin:auto;padding:2rem}.panel{max-width:42rem;background:#fff;border:1px solid #b9c8c0;border-radius:1rem;padding:1.25rem}label{font-weight:750;display:block;margin-bottom:.5rem}textarea{box-sizing:border-box;inline-size:100%;max-inline-size:100%;min-block-size:calc(3lh + 1.5rem);max-block-size:calc(12lh + 1.5rem);overflow-x:hidden;overflow-y:auto;overflow-wrap:anywhere;padding:.75rem;border:2px solid #507064;border-radius:.6rem;font:inherit;line-height:1.5;resize:vertical}.native textarea{field-sizing:content;height:auto}.fallback textarea{height:auto}.help,.error{font-size:.9rem}.error{color:#a02d4a}.controls{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0}button,select{font:inherit;min-height:44px;padding:.6rem .8rem}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#10261f;color:#eef7f2;padding:1rem;border-radius:.7rem}@media(max-width:32rem){body{padding:1rem}}</style><main><h1>CSS field-sizing textarea lab</h1><p>One semantic control, explicit minimum and cap, and a narrow scrollHeight fallback.</p><div class="controls"><label for="branch">Sizing branch</label><select id="branch"><option value="auto">Auto</option><option value="native">Native</option><option value="fallback">Fallback</option></select></div><form class="panel" id="panel"><label for="notes">Review notes</label><textarea id="notes" aria-describedby="notes-help notes-error" placeholder="Add the decision, evidence, and next step."></textarea><p class="help" id="notes-help">Three-line minimum; twelve-line maximum; content stays in this field.</p><p class="error" id="notes-error" hidden>Include a next step before submitting.</p><button id="reset" type="reset">Reset form</button></form><div class="controls" aria-label="Content fixtures"><button type="button" data-fixture="empty">Empty</button><button type="button" data-fixture="wrapped">Wrapped</button><button type="button" data-fixture="token">Long token</button><button type="button" data-fixture="rtl">RTL</button><button type="button" data-fixture="paste">30 lines</button><button type="button" data-fixture="programmatic">Programmatic update</button><button type="button" data-fixture="error">Error</button></div><h2>Support and measurement receipt</h2><pre id="receipt" tabindex="0"></pre></main><script>(()=>{"use strict";const MAX_VALUE=32768,fixtures={empty:"",wrapped:"The decision stays narrow while this sentence wraps across several lines in a small viewport. Evidence remains attached to the value and the next step remains visible.",token:"x".repeat(600),rtl:"هذا نص تجريبي طويل لاختبار اتجاه الكتابة والتفاف السطور داخل حقل مضبوط الارتفاع.",paste:Array.from({length:30},(_,i)=>"Line "+(i+1)+" keeps the field honest.").join("\n"),programmatic:"Programmatic updates need an explicit resync after the value changes.",error:"Needs evidence."};const area=document.querySelector("#notes"),panel=document.querySelector("#panel"),support=CSS.supports("field-sizing","content"),fixtureMatrix={};let lastAccepted="",lastError=null,lastSync={before:null,after:null};
function validate(value){if(typeof value!=="string"||value.length>MAX_VALUE)throw Error("invalid-or-oversized-value");return value;}
function digest(value){let h=2166136261;for(let i=0;i<value.length;i++){h^=value.charCodeAt(i);h=Math.imul(h,16777619);}return(h>>>0).toString(16).padStart(8,"0");}
function pixels(){const style=getComputedStyle(area),line=parseFloat(style.lineHeight),pad=parseFloat(style.paddingTop)+parseFloat(style.paddingBottom);return{min:3*line+pad,max:12*line+pad};}
function branch(){const requested=document.querySelector("#branch").value;return requested==="auto"?(support?"native":"fallback"):requested;}
function syncSize(){validate(area.value);lastSync.before=digest(area.value);if(branch()!=="fallback"){area.style.height="";area.style.overflowY="auto";lastSync.after=digest(area.value);return;}const bounds=pixels();area.style.height="auto";const next=Math.max(bounds.min,Math.min(bounds.max,area.scrollHeight));area.style.height=next+"px";area.style.overflowY=area.scrollHeight>bounds.max?"auto":"hidden";lastSync.after=digest(area.value);}
function measurement(){const bounds=pixels(),rect=area.getBoundingClientRect(),zoom=parseFloat(getComputedStyle(document.documentElement).zoom)||1,blockSize=rect.height/zoom;return{blockSize:Number(blockSize.toFixed(2)),minimum:Number(bounds.min.toFixed(2)),maximum:Number(bounds.max.toFixed(2)),scrollHeight:Number((area.scrollHeight/zoom).toFixed(2)),overflow:getComputedStyle(area).overflowY,scrolling:area.scrollHeight>rect.height+1};}
function receipt(){if(lastError)throw Error(lastError);const value=validate(area.value),zoom=parseFloat(getComputedStyle(document.documentElement).zoom)||1,described=area.getAttribute("aria-describedby").split(/\s+/),data={schema:"field-sizing-lab-v1",status:"PASS",support,branch:branch(),valueLength:value.length,valueDigest:digest(value),valueBeforeSizingDigest:lastSync.before,valueAfterSizingDigest:lastSync.after,valuePreserved:value===lastAccepted&&lastSync.before===lastSync.after,...measurement(),viewportWidth:document.documentElement.clientWidth,zoom,fixtureMatrix:structuredClone(fixtureMatrix),labelled:document.querySelector('label[for="notes"]')!==null,describedBy:described,descriptionsPersist:described.every(id=>document.getElementById(id)),insideForm:area.form===panel,resetControlType:document.querySelector("#reset").type,pageHorizontalOverflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,claimBoundary:"Feature detection selects a sizing branch; it does not prove identical geometry, assistive-technology behavior, or manual-resize quality."};if(data.blockSize<data.minimum-2||data.blockSize>data.maximum+2||!data.valuePreserved||!data.labelled||!data.descriptionsPersist||!data.insideForm||data.resetControlType!=="reset"||data.pageHorizontalOverflow)throw Error("receipt-invariant-failed");return data;}
function show(){requestAnimationFrame(()=>{try{document.querySelector("#receipt").textContent=JSON.stringify(receipt(),null,2);}catch(error){document.querySelector("#receipt").textContent=JSON.stringify({schema:"field-sizing-lab-v1",status:"FAIL",error:error.message});}});}
function showFailure(error,attemptedLength){document.querySelector("#receipt").textContent=JSON.stringify({schema:"field-sizing-lab-v1",status:"FAIL",error:error.message,attemptedLength,valueRestored:true,valueLength:area.value.length,valueDigest:digest(area.value)},null,2);}
function recordFixture(name){fixtureMatrix[name]={valueLength:area.value.length,valueDigest:digest(area.value),direction:area.dir||"auto",...measurement()};}
function applyFixture(name){const value=validate(fixtures[name]);lastError=null;area.value=value;lastAccepted=value;area.dir=name==="rtl"?"rtl":"auto";document.querySelector("#notes-error").hidden=name!=="error";syncSize();recordFixture(name);show();}
function programmaticResync(value){const next=validate(value);lastError=null;area.value=next;lastAccepted=next;syncSize();recordFixture("programmatic");show();return receipt();}
area.addEventListener("input",()=>{const attemptedLength=area.value.length;try{validate(area.value);lastError=null;lastAccepted=area.value;syncSize();recordFixture("typed");show();}catch(error){area.value=lastAccepted;syncSize();lastError=error.message;showFailure(error,attemptedLength);}});document.querySelector("#branch").addEventListener("change",()=>{panel.className="panel "+branch();try{syncSize();show();}catch(error){lastError=error.message;showFailure(error,area.value.length);}});panel.addEventListener("reset",()=>requestAnimationFrame(()=>{lastError=null;lastAccepted=area.value;area.dir="auto";document.querySelector("#notes-error").hidden=true;syncSize();recordFixture("reset");show();}));document.querySelectorAll("[data-fixture]").forEach(button=>button.addEventListener("click",()=>applyFixture(button.dataset.fixture)));globalThis.JPJournalLab={validate,syncSize,receipt,applyFixture,programmaticResync,fixtures,area};panel.className="panel "+branch();for(const name of Object.keys(fixtures))applyFixture(name);applyFixture("empty");})();</script></html>