registry:ui · Data Entry
textarea — the multiline text shell
NativeHTML textarea with the jixoai text shell; rows pass-through (default 4), resize: vertical. Same label/error contract as input.
$npx jixoai-ui add textarea
https://ui.jixoai.com/r/textarea.json
overview
Overview
The platform gives the multiline field; the family gives the text-shell law, six slot seams, a count readout with announcement discipline, and the label/error contract — all measured.
The native element carries the semantics: a real <textarea> — multiline text, platform
forms, FormData and form.reset untouched. The family adds the text-shell law (the same
one as input: a 1px var(--border) frame over var(--background), radius 0, a hover lift,
and the site's inset 1px focus-visible outline on the ring token), locks resize to the vertical axis (never horizontal —
it would break column rhythm), and wires the field contract: label[for] with an auto id, an error string that
becomes aria-invalid + aria-describedby + a dashed shell + the "! message"
line, and a count readout. rows defaults to 4; everything else — placeholder, maxlength,
disabled, name, required, spellcheck, wrap — flows through restProps onto the element.
The shell is a slot system. Inner block rows land inside the frame behind 1px hairlines
(a toolbar above, a status row below); outer rows replace or extend the field's
surroundings; and count appends an
N / maxLength readout with announcement discipline: code-point counting (a surrogate
pair counts ONE — measured 🫠🫠 = 2), aria-live off everywhere except the last 10% of the cap, where it flips to polite — near the
limit the count is worth announcing, elsewhere it never chatters per keystroke. value is bindable: bound ⇒ controlled; absent ⇒
purely uncontrolled (Svelte skips undefined writes, so FormData and form.reset keep
native behavior — measured). A caller-supplied oninput still fires after the family syncs.
The eight axes: density is no-opinion and REACTIVE — the family owns no density paint,
and the ambient scope channels move the field (measured 10px/60px at 2xs vs 15px/90px
at lg); size and color are CONSUMED by the §1 collision rule (the native element never
receives them — measured); theme is a declarative stamp whose story has three measured
strata (page bridge → canvas light-island → component stamp, closest scope wins);
shape/radius/elevation/motion supply unread (zero carrier readers, grep receipts).
Kinship: input (the single-line twin — same
shell law), combobox and select (the other field contracts).
textarea
the multiline half of the shell family — label + count rows, locked vertical resize, and the same label/error contract as input; value is $bindable.
<Textarea
label="notes"
rows={4}
bind:value
count
/>W3C foundation
What the platform gives
The native textarea keeps platform semantics — multiline text, FormData, form.reset, the attribute set (placeholder, maxlength, spellcheck, wrap…). The shell only paints: same 1px border, radius 0, hover lift, and inset focus outline as the input shell — only taller.
<Textarea label="notes" rows={5} maxlength={280} />
<!-- rows pass through (default 4); resize locks to the vertical axis;
every other attribute rides through restProps -->
<Textarea label="bio" rows={3} placeholder="multiline, maxlength rides through…" maxlength={280} spellcheck={false} wrap="hard" />types
Textarea variants
The plain shell, the toolbar + count posture, the error state, and the disabled field.
textarea · variants
bio is required
<script lang="ts">
import Textarea from '@ui/textarea.svelte';
</script>
<div class="grid gap-4 sm:grid-cols-2">
<div class="border border-border p-4"><Textarea label="plain" name="types-plain" rows={3} placeholder="multiline…" /></div>
<div class="border border-border p-4"><Textarea label="count" name="types-count" rows={3} maxlength={280} count placeholder="N / maxLength readout…" /></div>
<div class="border border-border p-4"><Textarea label="error" name="types-error" rows={2} error="bio is required"></Textarea></div>
<div class="border border-border p-4"><Textarea label="disabled" name="types-disabled" rows={2} placeholder="not allowed" disabled /></div>
</div>textarea
Slot system — toolbar + status rows
textarea adds inner-block-start / inner-block-end behind 1px hairlines for toolbar and status rows, plus count for an N / maxLength readout. The shell owns border, background, hover, and the inset focus outline, so slot content never repaints the box law — and value is $bindable: a bound field turns controlled, an unbound one stays purely uncontrolled (FormData and form.reset untouched).
Slot content lands muted at 0.75rem behind the hairline rows; the wrapper is scoped,
so override it with an important utility
(text-foreground!) or an inline style. The count
readout lives in the status row — N / maxLength,
counted in code points off the mirrored value, never a second source of truth.
<!-- textarea · 6 slots — inner rows sit behind 1px hairlines -->
<Textarea label="notes" rows={6} maxlength={280} count bind:value={text}>
{#snippet innerBlockStart()}<span>bold</span><span>italic</span>{/snippet}
{#snippet innerBlockEnd()}<span>draft</span>{/snippet}
</Textarea>law
label + error wiring
The error prop is pure semantics: it sets aria-invalid='true', wires aria-describedby to the “! message” line, and dashes the shell border — the border signal is monochrome on purpose (shape, not hue), while the message line itself rides var(--destructive).
bio is required
<Textarea label="bio" error="bio is required" rows={2}></Textarea>
<!-- error wiring, the family law:
· aria-invalid="true" on the control
· aria-describedby → the "! message" line (var(--destructive) ink)
· border-style: dashed on the shell — the BORDER signal is
monochrome on purpose (shape, not hue) -->usage
Usage
rows and every other native attribute pass through; the component adds the label/error contract, the hairline slot rows, and the count readout.
<Textarea label="notes" rows={5} maxlength={280} />
<!-- rows pass through (default 4); resize locks to the vertical axis;
every other attribute rides through restProps -->
<Textarea label="bio" rows={3} placeholder="multiline, maxlength rides through…" maxlength={280} spellcheck={false} wrap="hard" />theming
Density and tokens
The shell, lane padding, and label/error rhythm are pure density-scope tokens — the field is density-REACTIVE (measured 10px voice / 60px height at the 2xs stamp vs 15px / 90px at lg (rows-4 projections: height = rows × voice × 1.5 — the served rows-3 sample reads 45/68, the same arithmetic)) — and resize the scope and the whole field stack follows.
| Token | Default | Source |
|---|---|---|
| --jx-hit | 28 / 32 / 40 / 48px | density |
| --jx-text | 11 / 12 / 13 / 15px | density |
| --jx-line | 16 / 18 / 20 / 24px | density |
| --jx-inset | 8 / 8 / 12 / 16px | density |
| --jx-stack | 4 / 4 / 8 / 8px | density |
api
API
The declared surface is 20 named props (label, error, count, rows, id, value, chrome, class + the four slot snippets + the eight axes) — and the component is ATTRIBUTE-TRANSPARENT, not rest-less: the native textarea attribute set rides the rest spread onto the real element, forwarded BY NAME where the family gives it a job (maxlength drives the count readout; rows defaults to 4; oninput fires after the family syncs) and verbatim everywhere else (placeholder, disabled, name, required, spellcheck, wrap, minlength, readonly…). Two withholdings: the color ATTRIBUTE is Omitted (the color AXIS is not), and a caller data-density is captured by the axis stamp.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| label | string | — | Field label rendered as label[for] above the control; skipped when outerBlockStart takes the slot over. |
| error | string | — | Error text: sets aria-invalid="true", wires aria-describedby to the "! message" line, dashes the shell. |
| count | boolean | false | Appends an "N / maxLength" readout to the inner-block-end row (code-point counting; aria-live flips to polite at 90% of the cap). |
| rows | number | 4 | Native rows attribute, passed through verbatim. |
| id | string | $props.id() | Wired into label[for] and the error aria-describedby; auto-generated when omitted. |
value bind | string | number | — | Bindable; bound ⇒ controlled, absent ⇒ purely uncontrolled (FormData and form.reset keep native behavior). |
| chrome | 'frame' | … | ambient ?? 'frame' | The control-chrome lane; the ambient value comes from the ControlChrome context. |
| innerBlockStart | Snippet | — | Inside the shell, above the textarea (toolbar row, behind a hairline). |
| innerBlockEnd | Snippet | — | Inside the shell, below the textarea (status row). |
| outerBlockStart | Snippet | — | Outside the shell, above — replaces the label row when given. |
| outerBlockEnd | Snippet | — | Outside the shell, below — renders below the error line. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on textarea
Density is NO-OPINION AND REACTIVE — the family owns no density paint and the ambient scope channels move the field (measured 10px/60px at 2xs vs 15px/90px at lg); size and color are CONSUMED by the §1 collision rule (the native element never receives them); theme is a declarative stamp with three measured strata (page bridge → canvas light-island → component stamp, closest scope wins); shape/radius/elevation/motion supply unread (zero carrier readers).
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | NO-OPINION AND REACTIVE — the family owns no density paint (every Defaults slot is the no-opinion axis slot), so the AMBIENT scope channels flow straight through the field: --jx-text/--jx-line/--jx-inset/--jx-hit move the voice and the height (measured 10px voice / 60px height at the 2xs stamp vs 15px / 90px at lg (rows-4 projections: height = rows × voice × 1.5 — the served rows-3 sample reads 45/68, the same arithmetic)). The rung attr stamps the field root (data-density). Zero --jx-density-effective readers (grep receipt) — the channels are plain density-scope custom properties, not axis carriers. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | CONSUMED (§1) — the native element NEVER receives a size attribute from this axis (the §1 native collision rule; measured: size-stamped fields carry no size attribute); the §1 stamp scales the field root and the field voice follows (measured echo). Zero --jx-size-effective readers (grep receipt). Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — zero shape-channel readers (grep receipt: no corner-shape or factor consumer in ui/textarea/); the corner is the consumed jx-pure box law (measured 0px — square chrome). Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — zero radius-effective readers (grep receipt); the shell radius is the consumed box law (measured 0px), and a stamped carrier moved nothing (measured). Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | CONSUMED (§1) AND SUPPLY-ONLY — the hue axis never reaches the native element (the collision rule, measured); the shell paints var(--border)/var(--background) from the theme profile and the family’s one semantic color is the error line’s var(--destructive). Zero --jx-color-effective readers (grep receipt). Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | DECLARATIVE STAMP, AND THE HOST DECIDES — three strata, measured. L1 page bridge: under prefers-dark the SITE'S THEME SYNC adds html.dark (the mechanism — not the media query alone) and --border flips oklch(0 0 0) → oklch(1 0 0) page-wide and a body-level field re-derives. L2 host island: inside a component-canvas stage a data-theme="light" island re-pins the light profile — the served demo does NOT re-derive (island beats page). L3 component stamp: theme="dark" stamps the .dark class on the field root — the closest scope wins over the island. The ink has two halves: the shell face reads base tokens and flips at each stratum, but the typed text rides the ROOT-PINNED ALIAS chain and holds the root resolution — at the settled L3 stamp that is black text on the flipped shell (latent — no served L3 demo; the family ink-pair repair is queued, W-next #7). The invalid signal stays monochrome at every stratum (a dashed border is shape, not hue). system/auto = tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — zero elevation-carrier readers (grep receipt); the only shadow is the consumed box law’s hover lift (measured a 1px inset shadow — not a shadow tier). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SUPPLY-ONLY — zero --jx-motion-effective readers (grep receipt); the shell’s color/outline transitions ride the consumed jx-pure law, and the count readout’s announcement discipline (aria-live off → polite at 90% of the cap) is a semantic channel, not a motion one. Number unit: coefficient. |
Receipts: the shell law (resize locked vertical; radius 0; hover lift measured as a 1px inset shadow; focus-visible the inset 1px ring outline at offset −1px; rows 4 default; label[for] auto-wired) and the error contract (aria-invalid="true", aria-describedby resolving to the "! message" line, dashed shell) were measured on this page's served DOM (probe, task 68); the count readout (N / maxLength; code-point counting measured 🫠🫠 = 2; aria-live off → polite at 90% of the cap and back) and the native forwarding (maxlength, spellcheck, wrap, name read back off the element) the same way; the theme strata (body-level border oklch(0 0 0) → oklch(1 0 0) under page-dark, the canvas data-theme="light" island pinning the served demo, the component .dark stamp winning closest) were measured across two loads; the unread rows carry grep receipts over ui/textarea/. The query() seat below rides the md viewport key (48rem) on the size lane.
<script lang="ts">
import Textarea from '@ui/textarea.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (13px root) applies; at 48rem+ the md case (18px)
wins — the field voice follows, the native element never sees a size
attribute -->
<Textarea label="notes" size={query({ md: 18 }, 13)} rows={3} />textarea · query()
Media keys are min-width: below 48rem the base (13px root) applies; at 48rem and wider the md case wins (18px) — the field voice follows while the native element never sees a size attribute. The number lane goes bare. Resize across 48rem.
<script lang="ts">
import Textarea from '@ui/textarea.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (13px root) applies; at 48rem+ the md case (18px)
wins — the field voice follows, the native element never sees a size
attribute -->
<Textarea label="notes" size={query({ md: 18 }, 13)} rows={3} />textarea · universal props
<Textarea label="px number" size={14} density="small" rows={2} />
<Textarea label="named steps" size="large" radius="medium" rows={2} />a11y
Accessibility
The native textarea keeps platform semantics; the component wires the label and the validation message to the control, and the count readout announces only when it matters.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus to the textarea |
| any text | Types into the field |
| drag | Resize handle, locked to the vertical axis |
ARIA
| Attribute | Value | Description |
|---|---|---|
| label[for] | {id} | The label prop names the control (auto id; skipped when outerBlockStart takes the slot over — author your own wiring then) |
| aria-invalid | 'true' | Set on the native textarea when the error prop is provided (measured) |
| aria-describedby | {id}-error | Points at the "! message" validation line — a static read on focus, no live region chatter while typing |
| aria-live | off → polite | The count readout stays silent until 90% of maxlength, then announces politely (aria-atomic); measured flipping both ways |
| aria-invalid paint | dashed border | The shell’s invalid signal is monochrome (border-style dashed) — the message line carries var(--destructive) |