Textarea
A native textarea with the same field join as Input, and an optional height that grows with the content.
Textarea renders a real <textarea> with the same field join as
Input, and can grow its height to fit what is in it.
Useful for a message or a note; if the box should keep a fixed height, leave
autoSize off.
Grows to six rows, then scrolls.
<template>
<Field.Root
class="group flex w-[17rem] flex-col gap-1.5"
:options="{ name: 'message' }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Textarea
v-model="message"
:class="control"
auto-size
:max-rows="6"
rows="2"
placeholder=" "
/>
</span>
<Label :class="label">Message</Label>
</div>
</div>
<Field.Description :class="hint">
Grows to six rows, then scrolls.
</Field.Description>
</Field.Root>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Field, Label, Textarea } from '@maas/mirror/vue'
const message = ref('')
const box = [
'relative isolate flex min-h-24 max-h-48 w-full items-start overflow-y-auto scrollbar-none',
'rounded-component-lg border-surface border-2 p-[0.875rem]',
'cursor-text transition-all duration-100 ease-linear [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
'focus-within:py-[0.375rem] has-[textarea:not(:placeholder-shown)]:py-[0.375rem]',
'outline-4 outline-transparent focus-within:focus-ring',
'group-[[data-invalid=true]:not([data-focused=true])]:border-danger-subtle',
'group-[[data-invalid=true]:not([data-focused=true])]:bg-danger-subtle',
'group-data-[disabled=true]:border-disabled-subtle group-data-[disabled=true]:cursor-not-allowed',
].join(' ')
const stack = [
'group/stack flex max-h-full w-full flex-col-reverse',
'items-center justify-center gap-0 px-1',
'focus-within:gap-1 has-[textarea:not(:placeholder-shown)]:gap-1',
].join(' ')
const line = [
'flex w-full min-h-0 overflow-x-clip',
'group-focus-within/stack:min-h-[1.4rem]',
'has-[textarea:not(:placeholder-shown)]:min-h-[1.4rem]',
].join(' ')
const control = [
'type-component-lg text-surface w-full bg-transparent outline-none',
'scrollbar-none placeholder:text-transparent',
'[--mirror-textarea-min-height:2.625rem] [--mirror-textarea-resize:none]',
'group-[[data-invalid=true]:not([data-focused=true])]:text-danger-on-muted',
'data-[disabled=true]:text-disabled-solid',
].join(' ')
const label = [
'type-component-lg leading-[normal]! text-surface-muted',
'flex h-full w-full items-center [--mirror-label-cursor:text]',
"before:absolute before:-inset-0.5 before:-z-10 before:content-['']",
'group-focus-within/stack:h-auto group-focus-within/stack:text-[0.6875rem] group-focus-within/stack:[--mirror-label-cursor:auto]',
'group-has-[textarea:not(:placeholder-shown)]/stack:h-auto group-has-[textarea:not(:placeholder-shown)]/stack:text-[0.6875rem] group-has-[textarea:not(:placeholder-shown)]/stack:[--mirror-label-cursor:auto]',
'group-[[data-invalid=true]:not([data-focused=true])]:text-danger-muted',
'group-data-[disabled=true]:text-disabled-muted',
].join(' ')
const hint = 'type-component-2xs text-surface-muted'
</script>
Usage guidelines
maxRowsis worked out from the resolvedline-height. Aline-heightofnormalis a keyword rather than a length, so the height of one line is measured off the element’s own typography instead, which costs a layout read the first time. An explicitline-heightsaves it.- Set
idonField.Rootrather than on the textarea, becauseLabelpoints itsforat the field’s control ID and anidhere leaves it pointing at nothing.
Anatomy
Render it inside a Field.Root, or on its own.
| Name | Required | Description |
|---|---|---|
| Textarea | true | Renders a native <textarea>, joined to the surrounding Field. |
<script setup lang="ts">
import { Field, Label, Textarea } from '@maas/mirror/vue'
</script>
<template>
<Textarea v-model="notes" />
<Field.Root :options="{ name: 'message' }">
<Label>Message</Label>
<Textarea v-model="message" auto-size />
</Field.Root>
</template>
Examples
Controlled and uncontrolled
defaultValue seeds the value and leaves Textarea owning it, while v-model
hands it to you, and which of the two applies is
decided once at mount.
<template>
<Textarea default-value="Dear Robin," name="message" rows="4" />
<Textarea v-model="message" rows="4" placeholder="Say something" />
</template>
<script setup lang="ts">
const message = ref('')
</script>
Auto-sizing
autoSize grows the element to fit its content, measuring on mount and again
whenever the value, autoSize or maxRows change. Each measurement releases
the height for a frame and reads scrollHeight off the element itself, then
publishes it as a custom property the stylesheet applies.
<template>
<Textarea v-model="message" class="textarea" auto-size rows="2" />
</template>
<style>
.textarea {
--mirror-textarea-resize: none;
line-height: 1.5rem;
}
</style>
The element never gets an inline height, so your own CSS keeps the last word.
A width change or a font swap is not a value change, and leaves the last
measurement standing until the next keystroke.
Capping the growth
maxRows caps the growth and lets the element scroll past the cap, working the
height out from the resolved line-height plus the block padding and border.
Where line-height resolves to normal, one line is measured off a probe
carrying the element’s own typography.
<template>
<Textarea
v-model="message"
class="textarea"
auto-size
:max-rows="6"
rows="2"
/>
</template>
<style>
.textarea {
--mirror-textarea-resize: none;
line-height: 1.5rem;
padding: 0.5rem 0.75rem;
}
</style>
Inside a field
The join is Input’s, unchanged: the textarea takes the
field’s control ID, name and flags, and reports its value, its focus and its
blur back.
<template>
<Field.Root
:options="{ name: 'message', required: true, validationMode: 'onBlur' }"
>
<Label>Message</Label>
<Textarea v-model="message" auto-size :max-rows="8" />
<Field.Description>Markdown is supported.</Field.Description>
<Field.Error match="valueMissing">Write something first.</Field.Error>
</Field.Root>
</template>
API reference
Standalone. Ordinary props, no store. As on Input, attributes not mirrored
into props (rows, cols, placeholder, maxlength, wrap) fall through to
the element unchanged.
Textarea
Renders a <textarea>.
Props
Emits
Slot props
None. Textarea exposes focus(), blur() and select() on its instance
instead.
Data attributes
The field state set, plus one addition.
CSS variables
--mirror-textarea-min-height and --mirror-textarea-resize apply to every
textarea. The auto-sizing rules (block-size, max-block-size and
overflow-y) apply only under [data-auto-size='true'], and read private
properties the component writes.
Errors
Accessibility
Native throughout: no roles and no key handlers, so Enter inserts a newline and
never submits the form. Inside a Field it adds aria-describedby,
aria-invalid, aria-required and aria-readonly, and nothing else.