Skip to content
Download ZIP

Neutral palette · 7.7 KB ZIP File receipt View as Markdown View code

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.
cmp_textarea_field_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_textarea_field_01 version 1.0.0 with variant "neutral", 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
Neutral
Version
1.0.0
Digest
Full digest
sha256-2cc8a92bcd1af67328670c932815e2085931acd2f21a729ab94d33da3b3391ba
TextareaField.svelte Svelte · 10.6 KB Raw
<!--
	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"
						>&ensp;{requiredLabel}</span
					>{:else if marker === 'optional'}
					<span class="text-[13px] font-normal text-(--_muted)">&ensp;{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, #18181b);
		--_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>

Give 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 reserveError holds 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

Svelte
<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.

Svelte
<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:

Svelte
<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 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:

Svelte
{#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:

CSS
.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
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label, always shown. The placeholder never stands in for it.
valuestringNoNoneBindable value. Undefined renders an empty field.
namestringNoNoneForm field name, so native submission includes the field.
rowsnumberNo4Visible 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.
maxlengthnumberNoNoneNative 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) | falseNo(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.
hintstringNoNoneHelp text under the label, linked first in aria-describedby.
errorstring | nullNonullValidation 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.
requiredbooleanNofalseNative required attribute. Shows the Required marker unless requiredIndicator says otherwise.
requiredIndicator'required' | 'optional' | 'none'NoNoneWhich marker follows the label. Defaults to 'required' for required fields and 'none' otherwise.
requiredLabelstringNo'Required'Marker text for required fields.
optionalLabelstringNo'Optional'Marker text for optional fields.
errorPrefixstringNo'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.
reserveErrorboolean | numberNofalseHold 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.
idstringNoNoneTextarea id. Defaults to one generated per instance. The hint, error and footer always take generated ids.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

Change 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-accent colours the focus edge and its halo. Keep it at 3:1 against the page.
  • Danger: --textarea-field-danger colours the error edge, the error focus ring, the icon and the message. The default is red-700 (6.5:1 on white).
  • Neutrals: --textarea-field-ink is the label and value, --textarea-field-muted the hint, marker, footer and scrollbar, --textarea-field-placeholder the placeholder, --textarea-field-hairline the resting edge (keep it at 3:1, since it is the only thing marking an empty field) and --textarea-field-surface the fill.
  • Worked retone for a #09090b band: --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, errorPrefix and limitHint with the page.

Public CSS variables

VariableToken
--textarea-field-accentaccent
--textarea-field-dangerdanger
--textarea-field-inkink
--textarea-field-mutedmuted
--textarea-field-placeholderplaceholder
--textarea-field-hairlinehairline
--textarea-field-surfacesurface

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-describedby lists the hint, then the error, then the footer, then any aria-describedby you pass, each only when present. aria-invalid is set only while error is set.
  • With maxlength, the hint states the limit, so people know before a paste is trimmed. Pass limitHint in 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.