# Autosizing textarea

> A labelled textarea that grows a line at a time from minRows to maxRows, then scrolls, with an optional footer for a count or actions inside the same frame. CSS field-sizing first, a measured fallback elsewhere.

- ID: `cmp_autosize_textarea_01`
- Slug: `autosize-textarea-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T03:28:39Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/autosize-textarea-01
- Preview: https://pagesugar.com/preview/autosize-textarea-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-656d801bd41346b962ea961da06598570ee1e8be129997c13b2e0084306e0fed` |
| `blue` | Blue accent | no | `sha256-0a454a788c36a65d0225d0cfc3d7910cab81f0577a2f87bb40c257bb486aa026` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/autosize-textarea-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

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.

Required props: `label`

```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>
```

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.

## Usage guide

### 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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label. hideLabel keeps it for screen readers only. |
| `value` | `string` | no |  | Bindable value. Setting it from code resizes the field, as typing does. |
| `name` | `string` | no |  | Form field name, so native submission includes the field. |
| `minRows` | `number` | no | `2` | Height in lines before anything is typed. Also the server-rendered height. Rounded to a whole line, at least 1. |
| `maxRows` | `number` | no | `10` | Height in lines at which the field stops growing and scrolls. Never below minRows. |
| `hint` | `string` | no |  | Help text under the label, linked first in aria-describedby. |
| `error` | string \| null | no | `null` | Validation 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. |
| `errorPrefix` | `string` | no | `'Error:'` | Visually hidden text read before the error. |
| `footer` | `Snippet` | no |  | Content under the text inside the same frame: a count, a note, actions. Laid out as a wrapping row with its first and last items at either end. |
| `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. |
| `hideLabel` | `boolean` | no | `false` | Keep the label for screen readers only; a hint stays visible. Use it for a composer whose surroundings and placeholder make the purpose obvious. |
| `id` | `string` | no |  | Textarea id. Defaults to one generated per instance. The hint and error always take generated ids. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--autosize-textarea-accent` |
| `onAccent` | `--autosize-textarea-on-accent` |
| `danger` | `--autosize-textarea-danger` |
| `ink` | `--autosize-textarea-ink` |
| `muted` | `--autosize-textarea-muted` |
| `placeholder` | `--autosize-textarea-placeholder` |
| `hairline` | `--autosize-textarea-hairline` |
| `surface` | `--autosize-textarea-surface` |

## Accessibility

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

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `AutosizeTextarea.svelte`
- Suggested directory: `src/lib/components/autosize-textarea-01`
- Files: 1
- Artifact digest: `sha256-656d801bd41346b962ea961da06598570ee1e8be129997c13b2e0084306e0fed`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `AutosizeTextarea.svelte`

Role: entry · 14452 bytes · SHA-256 `742ce9fc28893c87479eb30d0059e073ab01a678e718b3566361c06cabcbeb82`

```svelte
<!--
	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>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-656d801bd41346b962ea961da06598570ee1e8be129997c13b2e0084306e0fed`
- Entry: `AutosizeTextarea.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_autosize_textarea_01/1.0.0/neutral/sha256-656d801bd41346b962ea961da06598570ee1e8be129997c13b2e0084306e0fed/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_autosize_textarea_01/1.0.0/neutral/sha256-656d801bd41346b962ea961da06598570ee1e8be129997c13b2e0084306e0fed/bundle.zip (8780 bytes, sha256 `a712ee6cd64659affa2a6e7d8ae98ee406fa4a67636cd4b0c454ce5ef16e9f96`)

Files:

- `AutosizeTextarea.svelte` (entry, 14452 bytes): https://pagesugar.com/artifacts/cmp_autosize_textarea_01/1.0.0/neutral/sha256-656d801bd41346b962ea961da06598570ee1e8be129997c13b2e0084306e0fed/source/AutosizeTextarea.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-0a454a788c36a65d0225d0cfc3d7910cab81f0577a2f87bb40c257bb486aa026`
- Entry: `AutosizeTextarea.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_autosize_textarea_01/1.0.0/blue/sha256-0a454a788c36a65d0225d0cfc3d7910cab81f0577a2f87bb40c257bb486aa026/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_autosize_textarea_01/1.0.0/blue/sha256-0a454a788c36a65d0225d0cfc3d7910cab81f0577a2f87bb40c257bb486aa026/bundle.zip (8783 bytes, sha256 `8084189314a8bd015cf1cd6cf251db06735a21e420f2e7490f94d303c3741413`)

Files:

- `AutosizeTextarea.svelte` (entry, 14452 bytes): https://pagesugar.com/artifacts/cmp_autosize_textarea_01/1.0.0/blue/sha256-0a454a788c36a65d0225d0cfc3d7910cab81f0577a2f87bb40c257bb486aa026/source/AutosizeTextarea.svelte
