Skip to content
Download ZIP

Neutral palette · 8.6 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_autosize_textarea_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_autosize_textarea_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-656d801bd41346b962ea961da06598570ee1e8be129997c13b2e0084306e0fed
AutosizeTextarea.svelte Svelte · 14.1 KB Raw
<!--
	A textarea that grows a whole line at a time from minRows to maxRows, then scrolls. The text
	and an optional footer (a count, a note, actions) share one framed shape and one focus edge, so
	growth reads as the writing surface getting taller rather than a box pushing a toolbar down.
	CSS field-sizing does the growing where the browser supports it; elsewhere a client effect
	measures the text. It is a plain native <textarea name>, so it submits before any script runs.
-->
<script lang="ts" module>
	export type AutosizeTextareaVariant = 'field' | 'composer';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLTextareaAttributes } from 'svelte/elements';

	type Passthrough = Omit<
		HTMLTextareaAttributes,
		'value' | 'id' | 'name' | 'rows' | 'class' | 'children' | 'aria-invalid'
	>;

	interface Props extends Passthrough {
		/** Visible label. Always rendered; hideLabel keeps it for screen readers only. */
		label: string;
		/** Bindable value. Programmatic changes resize the field too. */
		value?: string;
		/** Form field name for native submission. */
		name?: string;
		/** Height in lines before any text is typed, and the server-rendered height. */
		minRows?: number;
		/** Height in lines at which the field stops growing and scrolls. */
		maxRows?: number;
		/** 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;
		/** Read before the error by screen readers only, for other languages. */
		errorPrefix?: string;
		/** Content under the text inside the same frame: a count, a note, actions. */
		footer?: Snippet;
		/** 'field' is a form control with a 3:1 edge; 'composer' is a raised frame for comments and chat. */
		variant?: AutosizeTextareaVariant;
		/** Keep the label for screen readers only, for a composer whose placeholder says what to write. */
		hideLabel?: boolean;
		/** 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,
		minRows = 2,
		maxRows = 10,
		hint,
		error = null,
		errorPrefix = 'Error:',
		footer,
		variant = 'field',
		hideLabel = false,
		id,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	const hintId = `${uid}-hint`;
	const errorId = `${uid}-error`;

	/* Whole lines only, at least one, and the cap never below the floor. */
	const min = $derived(Math.max(1, Math.round(minRows) || 1));
	const max = $derived(Math.max(min, Math.round(maxRows) || min));

	const invalid = $derived(!!error);
	/* Hint first, then the error, then anything the consumer attached (a character counter). */
	const describedBy = $derived(
		[
			...new Set(
				[
					hint && hintId,
					invalid && errorId,
					...(rest['aria-describedby'] ?? '').split(/\s+/)
				].filter(Boolean)
			)
		].join(' ') || undefined
	);

	let textarea: HTMLTextAreaElement | undefined = $state();
	let shape: HTMLDivElement | undefined = $state();
	let footerRow: HTMLDivElement | undefined = $state();

	/* Browsers with field-sizing grow the field in CSS, before hydration and without measuring. */
	const sizesInCss = () =>
		typeof CSS !== 'undefined' &&
		typeof CSS.supports === 'function' &&
		CSS.supports('field-sizing', 'content');

	/*
	 * The fallback measure. The shape is held at its current height while the textarea is reset
	 * to auto, so the page never sees the momentary collapse and cannot scroll-jump. The CSS
	 * min and max block sizes clamp the result to whole lines between minRows and maxRows.
	 */
	function fit() {
		if (!textarea || !shape || sizesInCss()) return;
		const held = shape.style.minHeight;
		shape.style.minHeight = `${shape.offsetHeight}px`;
		/* No scrollbar while measuring: a classic one would narrow the text and add wrapped lines. */
		textarea.style.overflowY = 'hidden';
		textarea.style.height = 'auto';
		textarea.style.height = `${textarea.scrollHeight}px`;
		textarea.style.overflowY = '';
		shape.style.minHeight = held;

		/* At the cap, typing at the end keeps the caret's line in view. */
		const capped = textarea.scrollHeight > textarea.clientHeight;
		const typingAtEnd =
			textarea === document.activeElement && textarea.selectionEnd === textarea.value.length;
		if (capped && typingAtEnd) textarea.scrollTop = textarea.scrollHeight;
	}

	/* Re-measure on every value change, typed or bound, and when the line limits change. */
	$effect(() => {
		void [value, min, max, variant];
		fit();
	});

	/* A narrower column rewraps the text, so the fallback re-measures when the width changes. */
	$effect(() => {
		if (!textarea || sizesInCss() || typeof ResizeObserver === 'undefined') return;
		let width = textarea.clientWidth;
		const observer = new ResizeObserver(() => {
			if (!textarea || textarea.clientWidth === width) return;
			width = textarea.clientWidth;
			fit();
		});
		observer.observe(textarea);
		return () => observer.disconnect();
	});

	/*
	 * A press on the frame or the footer's empty space focuses the textarea, as a press inside one
	 * shape should. Presses on anything in the footer (a button, a link, a note someone may want to
	 * select) are left alone.
	 */
	function focusTextarea(event: PointerEvent) {
		if (!textarea || !footerRow || event.button !== 0) return;
		if (event.target !== event.currentTarget && event.target !== footerRow) return;
		if (textarea.matches(':disabled')) return;
		event.preventDefault();
		textarea.focus();
	}
</script>

<div
	class={['autosize-textarea min-w-0', className]}
	data-variant={variant}
	data-invalid={invalid ? '' : undefined}
	style:--_min-rows={min}
	style:--_max-rows={max}
>
	<!-- hideLabel hides the label alone; a hint stays visible, since it carries instructions. -->
	<div class={['grid', (!hideLabel || hint) && 'gap-2']}>
		<div class="min-w-0">
			<label
				for={inputId}
				class={[
					'block text-sm leading-5 font-medium text-pretty break-words text-(--_ink)',
					hideLabel && 'sr-only'
				]}
			>
				{label}
			</label>
			{#if hint}
				<p
					id={hintId}
					class={[
						'text-[13px] leading-5 text-pretty break-words text-(--_muted)',
						!hideLabel && 'mt-1'
					]}
				>
					{hint}
				</p>
			{/if}
		</div>

		<div class="min-w-0">
			<!-- svelte-ignore a11y_no_static_element_interactions -->
			<div
				bind:this={shape}
				class={[
					'autosize-textarea__shape flex flex-col bg-(--_fill) text-(--_ink)',
					variant === 'field'
						? 'rounded-xl border border-(--_edge)'
						: 'rounded-2xl shadow-(--_shadow-composer)'
				]}
				onpointerdown={focusTextarea}
			>
				<textarea
					bind:this={textarea}
					{...rest}
					bind:value
					{name}
					rows={min}
					id={inputId}
					aria-describedby={describedBy}
					aria-invalid={invalid ? 'true' : undefined}
					class={[
						'autosize-textarea__input block w-full min-w-0 resize-none outline-none',
						'py-(--_pad-y) text-sm leading-6 text-(--_ink) pointer-coarse:text-base',
						'break-words placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
						variant === 'field' ? 'px-3' : 'px-4'
					]}></textarea>
				{#if footer}
					<div
						bind:this={footerRow}
						class={[
							'autosize-textarea__footer flex min-w-0 flex-wrap items-center justify-between gap-2',
							'text-[13px] leading-5 text-(--_muted)',
							variant === 'field' ? 'px-1 pb-1' : 'px-2 pb-2'
						]}
					>
						{@render footer()}
					</div>
				{/if}
			</div>

			{#if invalid}
				<p id={errorId} class="mt-1 flex 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}
		</div>
	</div>
</div>

<style>
	/* Public tokens: set --autosize-textarea-* on the field or any ancestor to retone it. */
	.autosize-textarea {
		--_accent: var(--autosize-textarea-accent, #18181b);
		--_danger: var(--autosize-textarea-danger, #b91c1c);
		--_ink: var(--autosize-textarea-ink, #18181b);
		--_muted: var(--autosize-textarea-muted, #52525b);
		--_placeholder: var(--autosize-textarea-placeholder, #71717a);
		/* 3:1 against white: in the field variant the edge is what marks an empty field (WCAG 1.4.11). */
		--_hairline: var(--autosize-textarea-hairline, rgb(0 0 0 / 0.44));
		--_surface: var(--autosize-textarea-surface, #ffffff);
		/* Not used by the field itself: footer actions you pass read it as the text on an accent fill. */
		--_on-accent: var(--autosize-textarea-on-accent, #ffffff);

		--_edge: var(--_hairline);
		--_ring: var(--_accent);
		/* The frame's fill; read-only and disabled raise it. */
		--_fill: var(--_surface);
		/* Block padding of the text, per variant; the height formulas below add it to the lines. */
		--_pad-y: 0.5rem;
		/* Scroll shadows at the cap: their tone, and the covers that hide them at either end. */
		--_scroll-shade: color-mix(in oklab, var(--_ink) 10%, transparent);
		--_cover: calc(var(--_pad-y) + 0.75rem);
		/*
		 * The composer's one elevation: the same 3:1 edge as the field (it still marks a control,
		 * WCAG 1.4.11), a contact shadow and a soft lift, lit from above.
		 */
		--_shadow-composer:
			0 0 0 1px var(--_hairline), 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 40px rgb(0 0 0 / 0.08);
	}
	.autosize-textarea[data-variant='composer'] {
		--_pad-y: 0.75rem;
	}
	.autosize-textarea[data-invalid] {
		--_edge: var(--_danger);
		--_ring: var(--_danger);
	}

	/*
	 * Sizing. Heights are whole lines (1lh) plus the block padding, so they follow the text size
	 * and the page zoom. rows={minRows} sets the same floor before any CSS loads. field-sizing grows
	 * the field to its content where it is supported; the script's measure covers the rest. Below
	 * the cap the text never scrolls, so no scrollbar flickers in during growth.
	 *
	 * At the cap, text that scrolls into the padding fades out instead of being cut, and a soft
	 * shadow sits on any edge with text beyond it, so the overflow reads as more to come even with
	 * overlay scrollbars hidden at rest. The shadows are fixed to the viewport (scroll); fill-coloured
	 * covers ride on the content's ends (local) and hide them once you reach that end. Below the cap
	 * the covers always sit over the shadows and the padding holds no text, so nothing shows.
	 */
	.autosize-textarea__input {
		min-block-size: calc(var(--_min-rows) * 1lh + 2 * var(--_pad-y));
		max-block-size: calc(var(--_max-rows) * 1lh + 2 * var(--_pad-y));
		overflow-y: auto;
		scrollbar-width: thin;
		background:
			linear-gradient(var(--_fill), var(--_fill)) top / 100% var(--_cover) no-repeat local,
			linear-gradient(var(--_fill), var(--_fill)) bottom / 100% var(--_cover) no-repeat local,
			linear-gradient(var(--_scroll-shade), transparent) 0 var(--_pad-y) / 100% 0.75rem no-repeat
				scroll,
			linear-gradient(transparent, var(--_scroll-shade)) 0 calc(100% - var(--_pad-y)) / 100% 0.75rem
				no-repeat scroll;
		mask-image: linear-gradient(
			transparent,
			#000 var(--_pad-y),
			#000 calc(100% - var(--_pad-y)),
			transparent
		);
	}
	@supports (field-sizing: content) {
		.autosize-textarea__input {
			field-sizing: content;
		}
	}

	/* The edge and halo follow the textarea inside, so their transition lives here with them. */
	.autosize-textarea__shape {
		transition-property: border-color, box-shadow, background-color;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	@media (prefers-reduced-motion: reduce) {
		.autosize-textarea__shape {
			transition-duration: 0s;
		}
	}

	/* Hover only where typing is possible: one step darker edge on the field. */
	@media (hover: hover) {
		.autosize-textarea[data-variant='field']:not([data-invalid])
			.autosize-textarea__shape:hover:not(:has(textarea:disabled, textarea[readonly])) {
			--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
		}
	}

	/*
	 * Focus: the field draws a two-pixel edge (border plus one ring) with a soft halo; the composer
	 * swaps its one-pixel ring for a two-pixel accent ring with the same halo and keeps its lift.
	 * Both follow :focus-visible on the textarea, which every keyboard and pointer focus of a
	 * text control matches.
	 */
	.autosize-textarea[data-variant='field'] .autosize-textarea__shape:has(textarea:focus-visible) {
		border-color: var(--_ring);
		box-shadow:
			0 0 0 1px var(--_ring),
			0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
	}
	.autosize-textarea[data-variant='composer']
		.autosize-textarea__shape:has(textarea:focus-visible) {
		box-shadow:
			0 0 0 2px var(--_ring),
			0 0 0 6px color-mix(in oklab, var(--_ring) 12%, transparent),
			0 12px 40px rgb(0 0 0 / 0.08);
	}
	.autosize-textarea[data-variant='composer'][data-invalid] .autosize-textarea__shape {
		box-shadow:
			0 0 0 1px var(--_danger),
			0 1px 2px rgb(0 0 0 / 0.05),
			0 12px 40px rgb(0 0 0 / 0.08);
	}

	/* Read-only: a raised fill, full-strength text, still focusable and selectable. */
	.autosize-textarea__shape:has(textarea[readonly]) {
		--_fill: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}

	/* Disabled: raised fill, faded, and not interactive. */
	.autosize-textarea__shape:has(textarea:disabled) {
		--_fill: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
		opacity: 0.55;
		cursor: not-allowed;
	}

	/* Forced colours drop shadows and repaint borders: the composer gets a real edge, focus an outline. */
	@media (forced-colors: active) {
		.autosize-textarea[data-variant='composer'] .autosize-textarea__shape {
			outline: 1px solid CanvasText;
		}
		/* Prefixed with the root so it outranks the composer's resting outline above. */
		.autosize-textarea .autosize-textarea__shape:has(textarea:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>

Give it a label and a name and it renders a native textarea that submits with its form. It starts at minRows lines and grows with the text to maxRows, then scrolls. Bind value, pass a hint, set error when your own validation fails, and pass a footer snippet for a count or actions. It does not submit on Enter, limit length, validate, or format text.

Suggested location
src/lib/components/autosize-textarea-01
Required props
label

Limitations

  • Enter inserts a new line; it never submits. Add your own keydown handler for send-on-Enter, and check event.isComposing so it does not interrupt IME input.
  • It does not count or limit length. Pass maxlength through, and pair it with a character counter in the footer.
  • In browsers without field-sizing, sizing waits for hydration: an empty field is already the right height, but a prefilled value grows once the script runs.
  • The fallback measures when the value or the field's width changes. A web font that loads late with different metrics is caught on the next keystroke, not immediately.
  • No manual resize handle; the field sizes itself.
  • The footer is your markup. Inside the field variant (12 px frame, 4 px footer inset) give controls an 8 px radius; inside the composer (16 px frame, 8 px inset) also 8 px.

Example

Svelte
<script lang="ts">
	import AutosizeTextarea from '$lib/components/autosize-textarea-01/AutosizeTextarea.svelte';

	let comment = $state('');
</script>

<form method="POST" class="max-w-lg">
	<AutosizeTextarea
		label="Add a comment"
		name="comment"
		minRows={2}
		maxRows={8}
		placeholder="Write a comment"
		bind:value={comment}
	>
		{#snippet footer()}
			<span class="ps-2 text-[13px] text-zinc-600">Visible to your team</span>
			<button
				type="submit"
				class="h-8 rounded-lg bg-zinc-900 px-3 text-[13px] font-medium text-white hover:bg-zinc-700"
			>
				Comment
			</button>
		{/snippet}
	</AutosizeTextarea>
</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.

Autosizing textarea#

A textarea that starts at minRows lines, grows a line at a time as the text gets longer, and scrolls once it reaches maxRows. The text and an optional footer share one frame, so a count or a Comment button rides along under the text as it grows.

Svelte
<AutosizeTextarea label="Add a comment" name="comment" minRows={2} maxRows={8} bind:value>
	{#snippet footer()}
		<span class="ps-2 text-[13px] text-zinc-600">Visible to your team</span>
		<button type="submit" class="h-8 rounded-lg bg-zinc-900 px-3 text-[13px] text-white">
			Comment
		</button>
	{/snippet}
</AutosizeTextarea>

How it sizes#

  • Browsers with field-sizing: content (Chrome and Edge 123+, Safari 26.2+, Firefox 152+) do the growing in CSS. A prefilled value is the right height in the server-rendered HTML, before any script runs.
  • Elsewhere a client effect measures the text whenever the value changes, whether typed or set from code, and whenever the field's width changes. It holds the frame's height while it measures, so the page below never jumps.
  • Both paths share the same floor and cap in CSS: minRows and maxRows lines of 1lh plus the padding. The server renders rows={minRows}, so an empty field never shifts when the script starts.

Field or composer#

variant="field" (the default) is a form control: a 3:1 edge, a 12 px radius, a two-pixel focus edge. Use it in forms and comment boxes.

variant="composer" drops the field edge for a raised 16 px frame with a hairline ring and one soft shadow, for the reply box at the foot of a thread. Pair it with hideLabel and a placeholder that says who will read the reply, and put the send action last in the footer.

What it leaves to you#

  • Enter. It inserts a line break. For send-on-Enter, add a keydown handler that skips event.isComposing and Shift+Enter, and say so near the field.
  • Length. Pass maxlength through and put a counter in the footer.
  • Validation. Set error from your own checks; clear it with null.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label. hideLabel keeps it for screen readers only.
valuestringNoNoneBindable value. Setting it from code resizes the field, as typing does.
namestringNoNoneForm field name, so native submission includes the field.
minRowsnumberNo2Height in lines before anything is typed. Also the server-rendered height. Rounded to a whole line, at least 1.
maxRowsnumberNo10Height in lines at which the field stops growing and scrolls. Never below minRows.
hintstringNoNoneHelp text under the label, linked first in aria-describedby.
errorstring | nullNonullValidation message from your code. When set, the frame and focus ring turn to the danger colour, the message shows with an icon, and aria-invalid is set.
errorPrefixstringNo'Error:'Visually hidden text read before the error.
variant'field' | 'composer'No'field''field' is a form control with a 3:1 border and a 12 px radius. 'composer' is a raised 16 px frame with a one-pixel 3:1 ring and one soft shadow, for comment threads and chat.
hideLabelbooleanNofalseKeep the label for screen readers only; a hint stays visible. Use it for a composer whose surroundings and placeholder make the purpose obvious.
idstringNoNoneTextarea id. Defaults to one generated per instance. The hint and error always take generated ids.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

Change the copy, line limits and footer through props, and retone the field through eight --autosize-textarea-* CSS variables. Hover, read-only and the focus halo are mixed from those, so they follow any retone.

  • Line limits: minRows is the empty height and maxRows the cap. Comment boxes suit 2 to 8, composers 1 to 6, long notes 4 to 12. The text is 14 px on a 24 px line (16 px on touch), so each row is 24 px.
  • Accent: --autosize-textarea-accent colours the focus edge and its halo, and --autosize-textarea-on-accent is the text on it. The field paints no accent fill itself; footer actions you pass can read both as var(--_accent) and var(--_on-accent), so one retone moves the Comment or Send button with the focus ring. Keep the accent at 3:1 against the page.
  • Danger: --autosize-textarea-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: --autosize-textarea-ink is the label and text, --autosize-textarea-muted the hint and footer text, --autosize-textarea-placeholder the placeholder, --autosize-textarea-hairline the field's resting edge (keep it at 3:1, it marks an empty field) and --autosize-textarea-surface the fill.
  • Dark band (#09090b): --autosize-textarea-accent: #fafafa; --autosize-textarea-on-accent: #18181b; --autosize-textarea-ink: #fafafa; --autosize-textarea-muted: #a1a1aa; --autosize-textarea-placeholder: #a1a1aa; --autosize-textarea-hairline: rgb(255 255 255 / 0.35); --autosize-textarea-surface: #18181b; --autosize-textarea-danger: #f87171. Add color-scheme: dark on the band.
  • Cream page such as a bakery's #fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set --autosize-textarea-surface: #fffdf9.
  • Footer: two children sit at either end; put a note first and the action last, and give the action margin-inline-start: auto so it stays at the end when a narrow column wraps it onto its own row. Start footer text with ps-2 in the field variant so it lines up with the typed text. Footer controls take an 8 px radius in both variants.
  • Composer: use variant 'composer' with hideLabel and a placeholder that names who will read the reply, and put the send action last in the footer. It is the one raised surface in its area; do not put it inside another shadowed card.
  • Layout: the field fills its container. Stack fields with a 20 to 24 px gap and pass class for placement.

Public CSS variables

VariableToken
--autosize-textarea-accentaccent
--autosize-textarea-on-accentonAccent
--autosize-textarea-dangerdanger
--autosize-textarea-inkink
--autosize-textarea-mutedmuted
--autosize-textarea-placeholderplaceholder
--autosize-textarea-hairlinehairline
--autosize-textarea-surfacesurface

Accessibility#

On this page
  • A native <label for> names the textarea; with hideLabel it stays in the DOM as screen-reader text. Ids come from $props.id() or your id prop; the hint and error ids are always generated per instance.
  • aria-describedby lists the hint id, then the error id, then any aria-describedby you pass (a counter, for example), each only when present. aria-invalid is set only while error is set.
  • Growth changes nothing about semantics: it is still a native textarea, nothing is announced as it grows, focus never moves, and the page does not scroll except to keep the caret in view, as the browser does for any field.
  • Heights are in line units (lh), so the field grows with browser text size and zoom. Text is 16 px on touch screens so iOS does not zoom.
  • Enter inserts a line break, as in any textarea. If you add send-on-Enter, say so near the field and skip it while event.isComposing is true.
  • The error is never colour alone: icon, text and a visually hidden errorPrefix. It is not a live region; move focus or announce a summary when a submit fails.
  • Both variants mark the control with a 3:1 edge (WCAG 1.4.11): the field with its border, the composer with a one-pixel ring of the same hairline under its shadow. In forced-colours mode the composer gets a real outline.
  • A capped field fades the line that scrolls into its padding rather than cutting it, so the overflow is visible even with overlay scrollbars that hide at rest.
  • Footer content is yours: label icon-only buttons, and keep touch targets at 44 px on coarse pointers.

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.