Skip to main content
ASoc
Tutorial

HTML `<textarea>`: Auditing the Only One in This Codebase

One real <textarea> across 111 products, a honeypot beside it, and a silent mismatch between the field's missing maxlength and the server's 5,000-char cap.

The ASoc Team9 min read

An HTML <textarea> is a multi-line plain-text control — <textarea rows="5"></textarea>, no value attribute, default content goes between the tags — used wherever a visitor needs to type more than a single line. This codebase has exactly one real <textarea> across 111 products and every marketing page: the contact form's message field. Auditing that one element end to end — its markup, its server-side companion, and a silent mismatch between the two — covers more of what actually goes wrong with textareas than a tour of the attribute list would.

The one <textarea> in this codebase

// src/components/molecules/ContactForm.tsx
<textarea
  id="contact-message"
  name="message"
  rows={5}
  required
  placeholder="How can we help?"
  className="w-full rounded-lg border border-stroke-secondary bg-white px-4 py-3 text-sm text-text-color outline-none focus:border-primary dark:border-gray-700 dark:bg-gray-800 dark:text-white/80"
/>

rows={5} sets the starting height; there's no cols attribute at all, because w-full already fixes the width and cols only matters when nothing else constrains it — the two do the same job and only one is needed at a time. No maxlength either, which turns out to matter, in the next section. The label is a real <label htmlFor="contact-message">, not a placeholder standing in for one — placeholder="How can we help?" disappears the instant a visitor starts typing, so it can hint at content but can never carry the field's name.

The attributes, and which ones this field actually uses

AttributeWhat it doesUsed here?
rowsStarting height in text linesYes — 5
colsStarting width in charactersNo — w-full already sets width
maxlengthHard cap on characters the browser will acceptNo — see below
requiredBlocks form submission while emptyYes
placeholderHint text, gone on first keystrokeYes
wrapsoft (default, visual wrap only) vs hard (inserts real newlines at the wrap column)No — default soft
readonly / disabledLocks the field; disabled also excludes it from form submissionNo
resize (CSS, not an HTML attribute)Browser default is resize: both — a drag handle in the cornerNot overridden — default resize handle is present

That last row is worth a second look: nothing in ContactForm.tsx's className sets resize-none, so the browser's native corner-drag handle is live on this field. Inside a rounded-3xl card with fixed padding, a visitor dragging the handle can pull the textarea wider than its container before the border catches up on the next paint — a one-frame visual glitch, not a functional bug, and the reason some sites reach for resize-none on principle even when nobody's filed it as an issue.

The gap maxlength's absence creates

The textarea has no maxlength, so a browser will accept a message of any length. The server action behind it does cap it — quietly, and at a different number than a visitor would ever guess:

// src/lib/actions/contact.ts
const message = String(formData.get("message") ?? "")
  .trim()
  .slice(0, 5000);

Five thousand characters, sliced with no warning shown anywhere in the UI. A visitor who pastes a 6,000-character message sees their submission succeed — the success toast reads "Message sent — we'll reply within 2 business days" — while everything past character 5,000 was silently dropped before it ever reached the fetch call to Resend. Nothing in the response distinguishes a full send from a truncated one. Adding maxLength={5000} to the JSX <textarea> would fix this in one line: the browser stops accepting input at the same number the server already enforces, and the mismatch between "what the field allows" and "what actually gets sent" closes without touching the action at all.

A field that renders formData.get("company"), never a value

Two lines above the visible message field sits a second, invisible one:

// src/components/molecules/ContactForm.tsx
<div style={{ display: "none" }} aria-hidden="true">
  <label htmlFor="contact-company">Company</label>
  <input
    id="contact-company"
    name="company"
    type="text"
    tabIndex={-1}
    autoComplete="off"
  />
</div>

Not a <textarea>, but the same form and worth reading alongside it: a classic honeypot. display: none and aria-hidden="true" hide it from sighted users and screen readers alike; tabIndex={-1} and autoComplete="off" keep it out of the tab order and off browser autofill. A human visitor never sees it, never tabs into it, and never fills it in. A bot filling every field it finds in the raw HTML fills this one too — and the server action checks for exactly that:

// src/lib/actions/contact.ts
if (formData.get("company")) return { ok: true, message: "Message sent." };

The response is a fake success, not a rejection. Returning an error would teach an adaptive bot which field to leave blank next time; returning the same "Message sent" a real visitor gets teaches it nothing, while the message itself is discarded before Resend is ever called.

Why textarea shows up in two files that render zero of them

Grepping this codebase for textarea turns up the ContactForm field and two more hits that aren't fields at all — they're substrings inside a focus-trap query selector, in components that contain no <textarea> whatsoever:

// src/components/molecules/SavedTemplates.tsx
const focusables = panel.querySelectorAll<HTMLElement>(
  'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
);

SavedTemplates' side panel and PreviewModal's dialog both trap Tab inside themselves while open — the same accessibility requirement aria-modal="true" obligates: if the panel claims the rest of the page is inert, Tab has to actually respect that, or a keyboard user walks straight out of a "modal" the screen reader already told them they couldn't leave. Building that selector generically — every focusable element type, textarea included — costs nothing when the panel happens not to contain one, and means the trap keeps working correctly the day someone adds a text field to either panel without having to remember to update a hand-picked list. PreviewModal's own copy of the same trap carries one line SavedTemplates doesn't, guarding its cross-origin iframe's focusin event — the two components share the pattern but not a hook, on purpose: two call sites with one small difference apiece isn't yet a case for extracting shared code.

Common mistakes

MistakeSymptomFix
No maxlength on the field, a length cap in the server actionVisitor's long message silently truncates with no error and no warningSet maxLength on the <textarea> to the same number the server enforces
Relying on placeholder instead of a <label>Screen readers announce nothing once the field has focus and text; sighted users lose the hint the moment they start typingAlways pair a real <label htmlFor> with the field; placeholder is a bonus, never the only name
No resize-none, no max-width guardThe native corner-drag handle can pull the field wider than a fixed-width parent for one frame before layout catches upAdd resize-none when the surrounding layout can't tolerate a resize, or leave it and accept the one-frame artifact
Honeypot field returns a visible error when triggeredTeaches an adaptive scraper which field to skipReturn the same success response a real submission gets; discard the data silently
Building a focus-trap selector by hand-picking element types present todayBreaks silently the day a new focusable element (a <textarea>, an <a>) is added without updating the selectorQuery every generically-focusable type up front, even ones the panel doesn't currently contain

Frequently asked questions

Does a <textarea> need a value attribute like <input> does? No — that's the one syntax difference worth memorizing. <input value="..."> sets content via an attribute; a <textarea> takes its default content as text between the opening and closing tags: <textarea>default text</textarea>. In React, both are normally controlled through value/defaultValue props instead, which papers over the raw-HTML difference.

Should every <textarea> have maxlength? Only if something downstream also enforces a limit — otherwise it's a UX constraint with nothing to protect. This codebase's gap runs the other way: the server enforces 5,000 characters and the field enforces nothing, so a visitor gets no warning before the extra text disappears. Either matching the two or removing the server cap resolves it; leaving them mismatched is the actual mistake.

Is display: none + tabIndex={-1} enough to hide a honeypot field from a bot? It's enough to hide it from a human and from the tab order, which is the part that matters for UX. It does nothing against a bot that specifically parses for known honeypot patterns — no field-hiding technique defeats a scraper built to look for it. The value of a honeypot is filtering the large volume of unsophisticated form-spam bots that fill every field blindly, not stopping a targeted attacker.

Why does SavedTemplates.tsx's focus-trap selector list textarea if the panel has none? Because the selector is written once, generically, for "every element type that can hold keyboard focus" rather than audited against what the panel currently contains. That's deliberate: a selector scoped to today's markup silently stops trapping focus correctly the day someone adds a field the list doesn't mention.

Templates in this post

ASoc Surge (an AI-startup landing page), ASoc Synth (an AI workspace SaaS site) and ASoc Tempo (a time-tracking SaaS landing page) all ship the same contact-form shape audited above — one real <textarea>, one honeypot field, one server action underneath.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the nav landmark this same header pattern relies on, see HTML Nav: What a <div>-Based Navbar Actually Loses; for the disclosure widget this codebase built instead of a native alternative, Collapsible HTML.

Keep reading

Tutorial8 min read

A Waitlist Landing Page Is One Server Action and a Honeypot

This storefront's real waitlist form: the Resend Audience API's deprecated field a copy-pasted snippet would miss, and firing one analytics event per successful subscribe, not per render.

Read more
Tutorial9 min read

Web Accessibility Tools: Which Ones Caught This Site's Real Defects

Accessibility 100 on all 8 pages, and two real defects no scanner flagged. Four defects sorted by which tool found them — and why two were structurally invisible.

Read more
Tutorial10 min read

Is Webflow Good for Ecommerce? What the SKU Cap Actually Costs

Webflow's ecommerce plans cap items and charge a transaction fee on top. This storefront's entire commerce stack — entitlements, checkout, webhook — is 843 lines with no cap at all.

Read more