Skip to main content
Nexus Design System
GitHub

Textarea

Textarea takes multi-line text, such as notes or a bio. It accepts every native textarea attribute. For a single line, use Input.

'use client';

import { Textarea } from '@/components/nxds/components/textarea/textarea';

export default function TextareaDefault() {
  return (
    <div className="nx:w-[400px]">
      <Textarea placeholder="Tell us about yourself..." />
    </div>
  );
}

Installation

npx nxds-cli@latest add textarea

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import { Textarea } from '@/components/nxds/components/textarea/textarea';

Examples

Disabled

A disabled textarea shows muted colors and blocks editing.

'use client';

import { Textarea } from '@/components/nxds/components/textarea/textarea';

export default function TextareaDisabled() {
  return (
    <div className="nx:w-[400px]">
      <Textarea placeholder="Cannot edit this" disabled />
    </div>
  );
}

Invalid

Set aria-invalid to show an error border and error focus ring.

'use client';

import { Textarea } from '@/components/nxds/components/textarea/textarea';

export default function TextareaInvalid() {
  return (
    <div className="nx:w-[400px]">
      <Textarea
        defaultValue="Too short"
        aria-invalid
        aria-label="Invalid bio"
      />
    </div>
  );
}

With label

Link a Label to the textarea with htmlFor and id.

'use client';

import { Label } from '@/components/nxds/components/label/label';
import { Textarea } from '@/components/nxds/components/textarea/textarea';

export default function TextareaWithLabel() {
  return (
    <div className="nx:w-[400px]">
      <div className="nx:flex nx:flex-col nx:gap-2">
        <Label htmlFor="textarea-bio">Bio</Label>
        <Textarea id="textarea-bio" placeholder="A few words about you" />
      </div>
    </div>
  );
}

This example also needs: Label.

Character counter

Keep a character count in local state and link the helper text and count with aria-describedby.

Keep the note short enough for the activity feed.

56 / 160 characters

'use client';

import * as React from 'react';

import { Label } from '@/components/nxds/components/label/label';
import { Textarea } from '@/components/nxds/components/textarea/textarea';

function CharacterCounterTextarea() {
  const textareaId = React.useId();
  const descriptionId = React.useId();
  const counterId = React.useId();
  const maxLength = 160;
  const [value, setValue] = React.useState(
    'Shipped keyboard fixes and updated the empty state copy.'
  );

  return (
    <div className="nx:grid nx:gap-2">
      <Label htmlFor={textareaId}>Release note</Label>
      <Textarea
        id={textareaId}
        aria-describedby={`${descriptionId} ${counterId}`}
        value={value}
        maxLength={maxLength}
        onChange={(event) => setValue(event.currentTarget.value)}
      />
      <div className="nx:flex nx:items-start nx:justify-between nx:gap-3">
        <p
          id={descriptionId}
          className="nx:typography-body-small nx:text-muted-foreground"
        >
          Keep the note short enough for the activity feed.
        </p>
        <p
          id={counterId}
          className="nx:shrink-0 nx:typography-body-small nx:text-muted-foreground"
        >
          {value.length} / {maxLength} characters
        </p>
      </div>
    </div>
  );
}

export default function TextareaCharacterCounter() {
  return (
    <div className="nx:w-[400px]">
      <CharacterCounterTextarea />
    </div>
  );
}

This example also needs: Label.

Props

Textarea

Textarea props
PropTypeDefaultDescription
variant"borderless" | "bordered" | null"bordered"No description

Textarea takes multi-line text, such as notes or a bio. It accepts every native textarea attribute. For a single line, use Input.

<script setup lang="ts">
import { Textarea } from '@/components/textarea';
</script>

<template>
  <Textarea placeholder="Tell us about yourself..." />
</template>

Installation

  1. Install the following dependencies:

    npm install @vueuse/core@^14.1.0 class-variance-authority@^0.7.1
    
  2. Copy and paste the following code into your project.

    components/textarea/Textarea.vue
    <script setup lang="ts">
    import { useVModel } from '@vueuse/core';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { type TextareaVariants, textareaVariants } from '.';
    
    export interface TextareaProps {
      variant?: TextareaVariants['variant'];
      /** The value, for `v-model`. */
      modelValue?: string;
      /** The starting value when `v-model` isn't bound. */
      defaultValue?: string;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<TextareaProps>(), {
      variant: 'bordered',
    });
    const emits = defineEmits<{
      'update:modelValue': [value: string];
    }>();
    
    const model = useVModel(props, 'modelValue', emits, {
      passive: true,
      defaultValue: props.defaultValue,
    });
    </script>
    
    <template>
      <textarea
        :value="model"
        data-slot="textarea"
        :data-variant="variant"
        :class="cn(textareaVariants({ variant }), props.class)"
        @input="model = ($event.target as HTMLTextAreaElement).value"
      />
    </template>
    
    components/textarea/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { default as Textarea, type TextareaProps } from './Textarea.vue';
    
    export const textareaVariants = cva(
      [
        'nx:flex nx:box-border nx:min-h-16 nx:w-full nx:rounded-md nx:border-default',
        'nx:text-foreground nx:autofill-text-foreground nx:transition-field',
        'nx:placeholder:text-muted-foreground',
        'nx:px-3 nx:py-2 nx:typography-body-default',
        'nx:focus-visible:outline-default nx:focus-visible:outline-focus-default nx:focus-visible:border-focus-default',
        'nx:aria-invalid:border-error-border nx:aria-invalid:focus-visible:outline-focus-error nx:aria-invalid:focus-visible:border-focus-error',
        'nx:disabled:cursor-not-allowed nx:disabled:bg-disabled nx:disabled:autofill-bg-disabled nx:disabled:text-disabled-foreground nx:disabled:autofill-text-disabled-foreground nx:disabled:placeholder:text-disabled-foreground',
      ],
      {
        variants: {
          variant: {
            bordered:
              'nx:border-border-default nx:bg-container nx:autofill-bg-container nx:enabled:hover:bg-container-hover nx:enabled:hover:autofill-bg-container-hover nx:disabled:border-border-disabled',
            borderless:
              'nx:border-transparent nx:bg-control-background nx:autofill-bg-control-background nx:enabled:hover:bg-control-background-hover nx:enabled:hover:autofill-bg-control-background-hover',
          },
        },
        defaultVariants: {
          variant: 'bordered',
        },
      }
    );
    
    export type TextareaVariants = VariantProps<typeof textareaVariants>;
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Textarea } from '@/components/textarea';

Examples

Disabled

A disabled textarea shows muted colors and blocks editing.

<script setup lang="ts">
import { Textarea } from '@/components/textarea';
</script>

<template>
  <Textarea placeholder="Cannot edit this" disabled />
</template>

Invalid

Set aria-invalid to show an error border and error focus ring.

<script setup lang="ts">
import { Textarea } from '@/components/textarea';
</script>

<template>
  <Textarea
    default-value="Too short"
    aria-invalid="true"
    aria-label="Invalid bio"
  />
</template>

With label

Link a Label to the textarea with for and id.

<script setup lang="ts">
import { Label } from '@/components/label';
import { Textarea } from '@/components/textarea';
</script>

<template>
  <div class="nx:flex nx:flex-col nx:gap-2">
    <Label for="textarea-bio">Bio</Label>
    <Textarea id="textarea-bio" placeholder="A few words about you" />
  </div>
</template>

This example also needs: Label.

Character counter

Bind the value with v-model and link the helper text and count with aria-describedby.

<script setup lang="ts">
import { ref, useId } from 'vue';

import { Label } from '@/components/label';
import { Textarea } from '@/components/textarea';

const textareaId = useId();
const descriptionId = useId();
const counterId = useId();
const note = ref('Shipped keyboard fixes and updated the empty state copy.');
</script>

<template>
  <div class="nx:grid nx:gap-2">
    <Label :for="textareaId">Release note</Label>
    <Textarea
      :id="textareaId"
      v-model="note"
      :aria-describedby="descriptionId + ' ' + counterId"
      :maxlength="160"
    />
    <div class="nx:flex nx:items-start nx:justify-between nx:gap-3">
      <p
        :id="descriptionId"
        class="nx:typography-body-small nx:text-muted-foreground"
      >
        Keep the note short enough for the activity feed.
      </p>
      <p
        :id="counterId"
        class="nx:shrink-0 nx:typography-body-small nx:text-muted-foreground"
      >
        {{ note.length }} / 160 characters
      </p>
    </div>
  </div>
</template>

This example also needs: Label.

Props

Textarea

Textarea props
PropTypeDefaultDescription
defaultValuestringNo default documented

The starting value when v-model isn't bound.

modelValuestringNo default documented

The value, for v-model.

variant"bordered" | "borderless" | null"bordered"No description