Skip to content

Textarea

A native textarea with the same field join as Input, and an optional height that grows with the content.

View source View as Markdown

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.

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

  • maxRows is worked out from the resolved line-height. A line-height of normal is 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 explicit line-height saves it.
  • Set id on Field.Root rather than on the textarea, because Label points its for at the field’s control ID and an id here leaves it pointing at nothing.

Anatomy

Render it inside a Field.Root, or on its own.

NameRequiredDescription
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

PropTypeDefault
id
stringthe field’s controlId, else generated
modelValue
string | numberundefined
defaultValue
string | number''
autoSize
booleanfalse
maxRows
numberundefined
name
stringundefined
disabled
booleanundefined
readOnly
booleanundefined
required
booleanundefined

Emits

EmitPayload
update:modelValue
string | number
focus
FocusEvent
blur
FocusEvent

Slot props

None. Textarea exposes focus(), blur() and select() on its instance instead.

Data attributes

The field state set, plus one addition.

AttributeValue
data-disabled
true
data-readonly
true
data-required
true
data-valid
true
data-invalid
true
data-dirty
true
data-touched
true
data-filled
true
data-focused
true
data-auto-size
true

CSS variables

VariableDefault
--mirror-textarea-min-height
auto
--mirror-textarea-resize
vertical
--mirror-textarea-disabled-cursor
not-allowed

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

Code
duplicate_field_control

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.