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.
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
| Attribute | What it does | Used here? |
|---|---|---|
rows | Starting height in text lines | Yes — 5 |
cols | Starting width in characters | No — w-full already sets width |
maxlength | Hard cap on characters the browser will accept | No — see below |
required | Blocks form submission while empty | Yes |
placeholder | Hint text, gone on first keystroke | Yes |
wrap | soft (default, visual wrap only) vs hard (inserts real newlines at the wrap column) | No — default soft |
readonly / disabled | Locks the field; disabled also excludes it from form submission | No |
| resize (CSS, not an HTML attribute) | Browser default is resize: both — a drag handle in the corner | Not 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
| Mistake | Symptom | Fix |
|---|---|---|
No maxlength on the field, a length cap in the server action | Visitor's long message silently truncates with no error and no warning | Set 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 typing | Always pair a real <label htmlFor> with the field; placeholder is a bonus, never the only name |
No resize-none, no max-width guard | The native corner-drag handle can pull the field wider than a fixed-width parent for one frame before layout catches up | Add 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 triggered | Teaches an adaptive scraper which field to skip | Return the same success response a real submission gets; discard the data silently |
| Building a focus-trap selector by hand-picking element types present today | Breaks silently the day a new focusable element (a <textarea>, an <a>) is added without updating the selector | Query 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.
