Textarea field
A labelled multi-line plain-text field: Required or Optional marker, hint that states any maxlength, rows, vertical resize, and a footer row with the error at the start and a counter at the end.
cmp_textarea_field_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_textarea_field_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-aa97a45f776b2da4e6455dcfc369a24759c5d744a70e1af3817bcd0cade683fe
<!--
A multi-line plain-text field: a visible label with an optional Required or Optional marker, an
optional hint (which states the limit when maxlength is set), a native <textarea> that opens at
a set number of rows and resizes vertically by default, and a footer row under it holding a
consumer-set error at the start edge and an optional footer snippet, such as a character
counter, at the end edge. It is a plain native <textarea name>, so it submits with a form before
any script runs; it never validates on its own.
-->
<script lang="ts" module>
export type TextareaFieldSize = 'sm' | 'md';
export type TextareaFieldResize = 'none' | 'vertical' | 'both';
export type TextareaFieldRequiredIndicator = 'required' | 'optional' | 'none';
/** What the footer snippet receives, so a counter can read the length without binding value. */
export interface TextareaFieldFooterContext {
/** Characters in the current value. */
length: number;
/** The maxlength prop, when set. */
maxlength?: number;
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLTextareaAttributes } from 'svelte/elements';
type Passthrough = Omit<
HTMLTextareaAttributes,
| 'value'
| 'id'
| 'name'
| 'rows'
| 'required'
| 'maxlength'
| 'class'
| 'children'
| 'aria-invalid'
>;
interface Props extends Passthrough {
/** Visible label. Always shown; the placeholder never stands in for it. */
label: string;
/** Bindable value. */
value?: string;
/** Form field name for native submission. */
name?: string;
/** Visible rows when the field opens, and the least it can be resized to. */
rows?: number;
/** Which way the person can drag the field's corner. */
resize?: TextareaFieldResize;
/** Native hard limit. The hint states it; for long text prefer a soft limit with a counter. */
maxlength?: number;
/** The sentence the hint adds when maxlength is set; false only when your footer states the limit. */
limitHint?: ((max: number) => string) | false;
/** Help text under the label, linked with aria-describedby. */
hint?: string;
/** Validation message from the consumer. Sets aria-invalid; the component never validates. */
error?: string | null;
/** Under the field at the end edge: a character counter or a formatting note. */
footer?: Snippet<[TextareaFieldFooterContext]>;
/** Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. */
required?: boolean;
/** Which marker follows the label. Defaults to 'required' for required fields, otherwise 'none'. */
requiredIndicator?: TextareaFieldRequiredIndicator;
/** Marker text for required fields, for other languages. */
requiredLabel?: string;
/** Marker text for optional fields, for other languages. */
optionalLabel?: string;
/** Read before the error by screen readers only, for other languages. */
errorPrefix?: string;
/** sm for dense panels (13 px), md for forms (14 px); 16 px text on touch screens. */
size?: TextareaFieldSize;
/** Hold lines under the field for the error (true is one), so setting it moves nothing below. */
reserveError?: boolean | number;
/** Textarea id. Defaults to one generated per instance. */
id?: string;
/** Extra classes on the outer wrapper, for placement. */
class?: string;
}
let {
label,
value = $bindable(),
name,
rows = 4,
resize = 'vertical',
maxlength,
limitHint = (max: number) => `Up to ${max.toLocaleString('en-US')} characters.`,
hint,
error = null,
footer,
required = false,
requiredIndicator,
requiredLabel = 'Required',
optionalLabel = 'Optional',
errorPrefix = 'Error:',
size = 'md',
reserveError = false,
id,
class: className,
...rest
}: Props = $props();
const uid = $props.id();
const fieldId = $derived(id ?? `${uid}-textarea`);
/* Hint, error and footer ids come from the instance, so a supplied id can never collide. */
const hintId = `${uid}-hint`;
const errorId = `${uid}-error`;
const footerId = `${uid}-footer`;
const invalid = $derived(!!error);
const marker = $derived(requiredIndicator ?? (required ? 'required' : 'none'));
const length = $derived(value?.length ?? 0);
const limit = $derived(maxlength != null && limitHint ? limitHint(maxlength) : '');
/* At the limit the browser stops accepting input, so the sentence that says why steps up a tone. */
const full = $derived(maxlength != null && length >= maxlength);
/* Lines held for the error: true is one; a number is for copy you know wraps. */
const reserveLines = $derived(
reserveError === true ? 1 : Math.max(0, Math.round(Number(reserveError) || 0))
);
const describedBy = $derived(
[
...new Set(
[
(hint || limit) && hintId,
invalid && errorId,
footer && footerId,
...(rest['aria-describedby'] ?? '').split(/\s+/)
].filter(Boolean)
)
].join(' ') || undefined
);
</script>
<div
class={['textarea-field min-w-0', className]}
data-size={size}
data-invalid={invalid ? '' : undefined}
style:--_rows={Math.max(1, Math.round(rows))}
style:--_reserve={reserveLines || undefined}
>
<div class="grid gap-2">
<div class="min-w-0">
<label
for={fieldId}
class={[
'block leading-5 font-medium text-pretty break-words text-(--_ink)',
size === 'sm' ? 'text-[13px]' : 'text-sm'
]}
>
{label}{#if marker === 'required'}
<!-- The native required attribute already says so to assistive technology. The en
space is a break point, so a marker that wraps starts on the label's edge. -->
<span class="text-[13px] font-normal text-(--_muted)" aria-hidden="true"
> {requiredLabel}</span
>{:else if marker === 'optional'}
<span class="text-[13px] font-normal text-(--_muted)"> {optionalLabel}</span>{/if}
</label>
{#if hint || limit}
<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
{hint}{hint && limit ? ' ' : ''}{#if limit}<span
class={[
'tabular-nums transition-colors duration-150 ease-(--_ease) motion-reduce:transition-none',
full && 'font-medium text-(--_ink)'
]}>{limit}</span
>{/if}
</p>
{/if}
</div>
<div class="min-w-0">
<textarea
{...rest}
bind:value
{name}
{required}
{maxlength}
rows={Math.max(1, Math.round(rows))}
id={fieldId}
aria-describedby={describedBy}
aria-invalid={invalid ? 'true' : undefined}
class={[
'textarea-field__input block w-full max-w-full min-w-0 border px-3 py-2 leading-normal',
'min-h-(--_min-h) border-(--_edge) bg-(--_surface) text-(--_ink)',
'placeholder:text-(--_placeholder) pointer-coarse:text-base',
/* Colour only, so reduced motion keeps it: nothing moves. */
'transition-[border-color,box-shadow,background-color] duration-150 ease-(--_ease)',
/* Hover only where typing is possible: one step darker edge. */
'hover:not-read-only:not-focus-visible:border-(--_edge-hover)',
/* Focus: a two-pixel edge (border plus ring) and a soft halo. outline-hidden keeps
an outline in forced-colours mode, where shadows are dropped. */
'focus-visible:border-(--_ring) focus-visible:shadow-(--_focus) focus-visible:outline-hidden',
/* Read-only and disabled: a raised fill. Disabled also steps the value down to the
muted tone (not the placeholder's), quiets the edge and drops the grip. */
'read-only:bg-(--_fill-raised) disabled:cursor-not-allowed disabled:resize-none',
'disabled:border-(--_edge-disabled) disabled:text-(--_muted)',
size === 'sm' ? 'rounded-md text-[13px]' : 'rounded-lg text-sm',
resize === 'none' && 'resize-none',
resize === 'vertical' && 'resize-y',
resize === 'both' && 'resize'
]}></textarea>
{#if invalid || footer || reserveLines}
<div class="mt-1 flex min-h-(--_footer-h) flex-wrap items-start gap-x-4">
{#if invalid}
<p
id={errorId}
class="flex min-w-0 flex-[1_1_12rem] items-start gap-1 text-[13px] leading-5 text-(--_danger)"
>
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 4.75v3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="11" r="1" fill="currentColor" />
</svg>
</span>
<span class="min-w-0 text-pretty break-words"
><span class="sr-only">{`${errorPrefix} `}</span>{error}</span
>
</p>
{/if}
{#if footer}
<div
id={footerId}
class="ms-auto min-w-0 text-end text-[13px] leading-5 text-pretty break-words text-(--_muted) tabular-nums"
>
{@render footer({ length, maxlength })}
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
<style>
/* Public tokens: set --textarea-field-* on the field or any ancestor to retone it. */
.textarea-field {
--_accent: var(--textarea-field-accent, #1d4ed8);
--_danger: var(--textarea-field-danger, #b91c1c);
--_ink: var(--textarea-field-ink, #18181b);
--_muted: var(--textarea-field-muted, #52525b);
--_placeholder: var(--textarea-field-placeholder, #71717a);
/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
--_hairline: var(--textarea-field-hairline, rgb(0 0 0 / 0.44));
--_surface: var(--textarea-field-surface, #ffffff);
--_ease: cubic-bezier(0.2, 0, 0, 1);
--_edge: var(--_hairline);
--_edge-hover: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
--_ring: var(--_accent);
--_focus: 0 0 0 1px var(--_ring), 0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
--_fill-raised: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
--_edge-disabled: color-mix(in oklab, var(--_hairline) 45%, transparent);
/*
* The least the field can be, in its own lines: rows of text plus the padding and the border.
* 1lh resolves on the textarea, so it follows the size and the touch-screen text step.
*/
--_min-h: calc(var(--_rows) * 1lh + 1rem + 2px);
/* The footer row holds one 20 px line, or as many as reserveError asks for. */
--_footer-h: calc(var(--_reserve, 1) * 1.25rem);
}
/* Invalid: the edge and the focus ring both carry the error colour. */
.textarea-field[data-invalid] {
--_edge: var(--_danger);
--_edge-hover: var(--_danger);
--_ring: var(--_danger);
}
/* No utility spells a scrollbar colour: a quiet one for long content, in the muted tone. */
.textarea-field__input {
scrollbar-color: color-mix(in oklab, var(--_muted) 45%, transparent) transparent;
}
</style>
Usage#
On this pageGive it a label and a name and it renders a labelled native textarea that submits with its form before any script runs. Bind value, set rows, pass a hint, and set error when your own validation fails. It does not validate, sanitise, format, count by itself or grow with its content (see the autosizing textarea), and it holds no form state.
- Suggested location
src/lib/components/textarea-field-01- Required props
label
Limitations
- The component never validates. The native required and maxlength attributes still act on submit and on input unless the form sets novalidate.
- maxlength is a hard limit: the browser stops typing at it and trims a paste that runs over it. The hint states the limit so nobody is cut off without warning, but for long text prefer a soft limit: no maxlength, a counter in the footer, and an error from your own check.
- It does not grow with its content. Text past the rows scrolls inside the field; drag the corner for more room. An autosizing textarea is a separate component.
- The footer snippet is your markup and is not a live region. A counter that announces itself should do so politely and only when the person pauses.
- The error appears in the footer row when you set it and the field grows to fit it, unless a footer is present or
reserveErrorholds its lines (pass a number for copy that wraps). A longer error still grows the field. - resize 'both' lets the corner drag sideways but never past the field's column; the footer stays aligned to the column's end edge.
Example
<script lang="ts">
import TextareaField from '$lib/components/textarea-field-01/TextareaField.svelte';
let message = $state('');
let error = $state<string | null>(null);
function check(event: SubmitEvent) {
error = message.trim() ? null : 'Tell us a little about your team, so we can bring the right examples.';
if (error) event.preventDefault();
}
</script>
{#snippet counter({ length }: { length: number })}
{length} / 2,000
{/snippet}
<form method="POST" class="max-w-lg" onsubmit={check}>
<TextareaField
label="Message"
name="message"
rows={5}
required
hint="Your team size and what you plan today help us bring the right examples."
bind:value={message}
{error}
footer={counter}
/>
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Textarea field#
The multi-line sibling of the text field: the same label, marker, hint and error wiring, around a
native <textarea> that opens at a set number of rows.
<TextareaField
label="Message"
name="message"
rows={5}
required
hint="Your team size and what you plan today help us bring the right examples."
bind:value={message}
{error}
/>Height and resizing#
rows sets the opening height, and the field never resizes below it. Text past those rows
scrolls inside the field; nothing is clipped. resize controls the corner: 'vertical' (the
default) keeps the column intact, 'none' fixes the height for a tight panel, and 'both' lets
the corner drag sideways too, never wider than the field's container. The field does not grow
with its content; that is the autosizing textarea.
Limits#
maxlength is the browser's hard limit. Typing stops at it and a paste that runs over it is
trimmed, so the field adds the limit to its hint ("Up to 500 characters.") before anyone starts,
and that sentence steps up to the ink tone once the limit is reached. Translate it with
limitHint, or pass false when your footer counter already states the limit:
<TextareaField label="Message" maxlength={500} limitHint={(max) => `Jusqu'à ${max} caractères.`} />For long text a soft limit is kinder: leave maxlength off, show a counter in the footer, and set
error when your own check finds the text too long. Nothing the person typed or pasted is lost.
Footer#
footer renders under the field at the end edge, on the same row as the error, which sits at the
start. It receives { length, maxlength }, so a counter needs no binding of its own:
{#snippet counter({ length }: { length: number })}
{length} / 2,000
{/snippet}
<TextareaField label="Message" name="message" footer={counter} {error} />On a right-to-left page, give the counter dir="ltr" so "59 / 200" keeps its order.
The footer is described by the field after the hint and the error, and it is not a live region.
If your counter announces itself, give it a separate polite status that updates when typing
pauses, not on every key. While a footer is present the row is always there, so a short error
appearing beside it moves nothing below. For copy you know wraps at narrow widths, pass
reserveError={2} (or more) to hold that many lines; anything longer still grows the field.
Validation is yours#
The field never validates. Set error when your check or your server rejects the value, and
clear it (null) when it passes. Keep the text the person wrote; never clear a field because it
failed. When a submit fails, move focus to the first invalid field or an error summary.
Native attributes#
Anything else you pass (placeholder, readonly, disabled, spellcheck, autocomplete,
wrap, minlength, event handlers) goes straight to the <textarea>. Keep instructions in the
label and hint; a placeholder is an example, not an instruction.
Retoning#
On a #09090b band:
.band {
background: #09090b;
color-scheme: dark;
--textarea-field-accent: #fafafa;
--textarea-field-ink: #fafafa;
--textarea-field-muted: #a1a1aa;
--textarea-field-placeholder: #a1a1aa;
--textarea-field-hairline: rgb(255 255 255 / 0.35);
--textarea-field-surface: #18181b;
--textarea-field-danger: #f87171;
}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label, always shown. The placeholder never stands in for it. |
value | string | No | None | Bindable value. Undefined renders an empty field. |
name | string | No | None | Form field name, so native submission includes the field. |
rows | number | No | 4 | Visible rows when the field opens, and the least it can be resized to. Text past them scrolls. |
resize | 'none' | 'vertical' | 'both' | No | 'vertical' | Which way the corner drags. Vertical keeps layouts intact; 'both' never goes wider than the column. |
maxlength | number | No | None | Native hard limit in characters. Adds the limit to the hint. For long text prefer a soft limit with a footer counter. |
limitHint | ((max: number) => string) | false | No | (max) => `Up to ${max.toLocaleString('en-US')} characters.` | The sentence added to the hint when maxlength is set, for other languages. false drops it; only do that when your footer states the limit. |
hint | string | No | None | Help text under the label, linked first in aria-describedby. |
error | string | null | No | null | Validation message from your code. When set, the field shows it with an icon at the start of the footer row, turns its edge and focus ring to the danger colour and sets aria-invalid. |
footer | Snippet<[{ length: number; maxlength?: number }]> | No | None | Under the field at the end edge, sharing a row with the error: a character counter or a formatting note. Receives the current length and maxlength. |
required | boolean | No | false | Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. |
requiredIndicator | 'required' | 'optional' | 'none' | No | None | Which marker follows the label. Defaults to 'required' for required fields and 'none' otherwise. |
requiredLabel | string | No | 'Required' | Marker text for required fields. |
optionalLabel | string | No | 'Optional' | Marker text for optional fields. |
errorPrefix | string | No | 'Error:' | Visually hidden text read before the error. |
size | 'sm' | 'md' | No | 'md' | 13 or 14 px text. Both use 16 px text on touch screens. |
reserveError | boolean | number | No | false | Hold lines under the field for the error: true holds one, a number holds that many, for copy you know wraps at narrow widths. Setting an error that fits moves nothing below; a longer one still grows the field. |
id | string | No | None | Textarea id. Defaults to one generated per instance. The hint, error and footer always take generated ids. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange the copy through props and retone the field through seven --textarea-field-* CSS variables. Hover, read-only and the focus halo are mixed from those, so they follow any retone.
- Accent:
--textarea-field-accentcolours the focus edge and its halo. Keep it at 3:1 against the page. - Danger:
--textarea-field-dangercolours the error edge, the error focus ring, the icon and the message. The default isred-700(6.5:1 on white). - Neutrals:
--textarea-field-inkis the label and value,--textarea-field-mutedthe hint, marker, footer and scrollbar,--textarea-field-placeholderthe placeholder,--textarea-field-hairlinethe resting edge (keep it at 3:1, since it is the only thing marking an empty field) and--textarea-field-surfacethe fill. - Worked retone for a
#09090bband:--textarea-field-accent:#fafafa;--textarea-field-ink:#fafafa;--textarea-field-muted:#a1a1aa;--textarea-field-placeholder:#a1a1aa;--textarea-field-hairline: rgb(255 255 255 / 0.35);--textarea-field-surface:#18181b;--textarea-field-danger:#f87171. Add color-scheme: dark on the band so the resize grip and scrollbar follow. - Cream page such as a bakery's
#fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set--textarea-field-surface:#fffdf9. - Height: rows sets the opening height and the floor; 3 for a short note, 4 or 5 for a message, 8 or more for a description people will draft in place.
- Limits: for a short field with a real storage limit, set maxlength and let the hint state it. For long text, leave maxlength off, pass a counter as footer and set error when your check finds it too long.
- Layout: the field fills its container. Stack fields with a 20 to 24 px gap and pass class for placement. Translate
requiredLabel,optionalLabel,errorPrefixandlimitHintwith the page.
Public CSS variables
| Variable | Token |
|---|---|
--textarea-field-accent | accent |
--textarea-field-danger | danger |
--textarea-field-ink | ink |
--textarea-field-muted | muted |
--textarea-field-placeholder | placeholder |
--textarea-field-hairline | hairline |
--textarea-field-surface | surface |
Accessibility#
On this page- A native <label for> names the textarea. Its id comes from
$props.id()or your id prop; the hint, error and footer ids are always generated per instance. aria-describedbylists the hint, then the error, then the footer, then anyaria-describedbyyou pass, each only when present.aria-invalidis set only while error is set.- With maxlength, the hint states the limit, so people know before a paste is trimmed. Pass
limitHintin the page's language. - The Required marker is visible text hidden from assistive technology, because the native required attribute already announces it. The Optional marker is part of the label's name.
- The error is never colour alone: it has an icon, text, and a visually hidden
errorPrefix. It is not a live region; move focus or announce a summary when a submit fails, as your form decides. - The footer is not a live region either. A counter that announces should use a separate polite status, updated when typing pauses, not on every key.
- Focus draws a two-pixel edge in the accent (the danger colour while invalid) plus a soft halo; in forced-colours mode it becomes a two-pixel Highlight outline.
- Read-only fields stay in the tab order and submit their value; disabled fields leave the tab order and do not submit. Explain a disabled field in its hint.
- The textarea uses 16 px text on touch screens, so iOS does not zoom on focus. Do not put instructions only in the placeholder.
Release details#
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.