Textarea

Displays a form textarea for several lines of text.

The box grows as you type.
import * as stylex from "@stylexjs/stylex"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"
import { Textarea } from "@/components/ui/textarea"
import { color, leading, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "24rem" },
  hint: { color: color.mutedForeground, fontSize: text.sm, lineHeight: leading.sm },
})

export default function TextareaDemo() {
  return (
    <VStack gap={2} xstyle={styles.field}>
      <Label htmlFor="textarea-demo-message">Message</Label>
      <Textarea id="textarea-demo-message" placeholder="Type your message here." />
      <span {...stylex.props(styles.hint)}>The box grows as you type.</span>
    </VStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts from the same page. The textarea's field surface, its focus ring and its invalid ring come from it. The examples caption their textareas with a Label or a Field.

Paste field-state.ts from the same installation page.

Copy and paste the following code into your project.

components/ui/textarea.tsx
"use client"

import { use } from "react"
import { Field as FieldPrimitive } from "@base-ui/react/field"
import { FieldStateContext } from "@/lib/field-state"
import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { breakpoints } from "@/theme/breakpoints.stylex"
import { states } from "@/theme/states"
import { color, duration, ease, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  base: {
    width: "100%",
    minWidth: 0,
    minHeight: space.s16,
    fieldSizing: "content",
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s2_5,
    paddingBlock: space.s2,
    fontSize: { default: text.base, [breakpoints.md]: text.sm },
    lineHeight: { default: leading.base, [breakpoints.md]: leading.sm },
    color: color.foreground,
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    opacity: { default: 1, ":disabled": 0.5 },
    pointerEvents: { default: null, ":disabled": "none" },
    "::placeholder": { color: color.mutedForeground },
  },
  inGroup: {
    flexGrow: 1,
    flexBasis: 0,
    minHeight: space.s16,
    borderWidth: 0,
    borderRadius: 0,
    backgroundColor: { default: color.transparent, ":disabled": color.transparent },
    boxShadow: { default: "none", ":focus-visible": "none" },
    opacity: { default: 1, ":disabled": 1 },
    resize: "none",
  },
})

const textareaStyles = {
  base: [styles.base, states.focusRing, states.fieldSurface],
  invalid: states.invalidRing,
  inGroup: styles.inGroup,
}

type TextareaProps = Omit<useRender.ComponentProps<"textarea">, "className"> & {
  xstyle?: StyleXStyles
  className?: string
}

function TextareaHost({
  render,
  ref,
  style,
  "aria-invalid": ariaInvalid,
  xstyle,
  className,
  ...props
}: TextareaProps) {
  const invalid = ariaInvalid != null && ariaInvalid !== false
  const sx = stylex.props(
    styles.base,
    states.focusRing,
    states.fieldSurface,
    invalid && states.invalidRing,
    xstyle,
  )

  return useRender({
    defaultTagName: "textarea",
    render,
    ref,
    props: {
      "data-slot": "textarea",
      ...sx,
      ...props,
      "aria-invalid": invalid || undefined,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function Textarea(props: TextareaProps) {
  const field = use(FieldStateContext)
  if (!field) return <TextareaHost {...props} />
  return (
    <FieldPrimitive.Control
      value={props.value}
      defaultValue={props.defaultValue}
      name={props.name}
      disabled={props.disabled}
      render={
        <TextareaHost
          {...props}
          disabled={field.disabled || props.disabled}
          aria-invalid={field.valid === false || props["aria-invalid"]}
        />
      }
    />
  )
}

export { Textarea, textareaStyles }

Update the import paths to match your project setup.

Usage

import { Textarea } from "@/components/ui/textarea"
<Textarea placeholder="Type your message here." />

The control is a plain <textarea>, so value, defaultValue, onChange, name, maxLength and the rest of the element's attributes work as they always do. Pair it with a Label and match htmlFor to the textarea's id.

The box sizes itself to its content from a two-line floor, so it grows as the text does instead of scrolling at a fixed height.

rows sets no height here. field-sizing: content measures the content instead, and the attribute is ignored. Pass a minHeight or a maxHeight through xstyle to change where the growing starts and stops.

Field

Use a Field to add a label and a description around the control.

Enter your message below.

import * as stylex from "@stylexjs/stylex"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "24rem" },
})

export default function TextareaField() {
  return (
    <Field xstyle={styles.field}>
      <FieldLabel>Message</FieldLabel>
      <FieldDescription>Enter your message below.</FieldDescription>
      <Textarea id="textarea-field-message" placeholder="Type your message here." />
    </Field>
  )
}

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import * as stylex from "@stylexjs/stylex"
import { Field, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "24rem" },
})

export default function TextareaDisabled() {
  return (
    <Field disabled xstyle={styles.field}>
      <FieldLabel>Message</FieldLabel>
      <Textarea id="textarea-disabled-message" placeholder="Type your message here." />
    </Field>
  )
}

Invalid

Set invalid on Field to style its control, or aria-invalid on a bare control. The examples show both.

import { Textarea } from "@/components/ui/textarea"
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

export default function TextareaInvalid() {
  return (
    <VStack gap={5} width="100%" maxWidth="24rem">
      <VStack gap={2}>
        <Label htmlFor="textarea-invalid-bare">Message</Label>
        <Textarea
          id="textarea-invalid-bare"
          defaultValue="Hi"
          aria-invalid
          aria-describedby="textarea-invalid-bare-error"
        />
        <FieldError id="textarea-invalid-bare-error">Write at least 20 characters.</FieldError>
      </VStack>
      <Field invalid>
        <FieldLabel>Message</FieldLabel>
        <Textarea defaultValue="Hi" />
        <FieldError>Write at least 20 characters.</FieldError>
      </Field>
    </VStack>
  )
}

Button

Put the textarea and its submit button in a VStack for a comment form.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { HStack, VStack } from "@/components/ui/stack"
import { Textarea } from "@/components/ui/textarea"

const styles = stylex.create({
  form: { width: "100%", maxWidth: "24rem" },
  actions: { justifyContent: "flex-end" },
})

export default function TextareaButton() {
  return (
    <VStack gap={2} xstyle={styles.form}>
      <Textarea placeholder="Type your message here." aria-label="Message" />
      <HStack xstyle={styles.actions}>
        <Button>Send message</Button>
      </HStack>
    </VStack>
  )
}

Input group

To draw one frame around the textarea and a toolbar, wrap it in an InputGroup. There is no InputGroupTextarea. Give the control data-slot="input-group-control" so the group's focus ring finds it, and textareaStyles.inGroup so it drops its own frame.

Markdown supported
import * as stylex from "@stylexjs/stylex"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText } from "@/components/ui/input-group"
import { Textarea, textareaStyles } from "@/components/ui/textarea"

const styles = stylex.create({
  group: { width: "100%", maxWidth: "24rem" },
  toolbar: { justifyContent: "space-between" },
})

export default function TextareaInputGroup() {
  return (
    <InputGroup xstyle={styles.group}>
      <Textarea
        placeholder="Leave a comment"
        aria-label="Comment"
        data-slot="input-group-control"
        xstyle={textareaStyles.inGroup}
      />
      <InputGroupAddon align="block-end" xstyle={styles.toolbar}>
        <InputGroupText>Markdown supported</InputGroupText>
        <InputGroupButton variant="default">Comment</InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Textarea renders a <textarea> and accepts every attribute the element takes.

Textarea

PropTypeDefault
disabledbooleanfalse
aria-invalidboolean—
placeholderstring—
xstyleStyleXStyles—
classNamestring—

textareaStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
baseThe control: frame, fill, type scale, placeholder, disabled
invalidThe destructive border and ring
inGroupThe frameless form a textarea takes inside an InputGroup

base is three styles, in merge order: the control's own, the shared states.focusRing, and states.fieldSurface, which is the hairline edge, the fill and the drop. invalid is states.invalidRing from theme/states.ts. Set a border of your own through --carbon-border-color rather than borderColor. A flat borderColor replaces the property with all of its conditions, so the ring loses its focus colour.

Data attributes

AttributeOnEffect
data-slot="textarea"the controlTargets the control from a parent
aria-invalidthe controlSet from the prop, and dropped when it is false