Textarea

PreviousNext

A multi-line text input component with resize controls, auto-resize, and character counting.

Component textarea-basic not found in registry.

Component textarea-char-counter not found in registry.

Component textarea-error not found in registry.

Component textarea-disabled not found in registry.

Component textarea-readonly not found in registry.

Component textarea-label-hint not found in registry.

Component textarea-custom-container not found in registry.

Component textarea-long-content not found in registry.

components/ui/textarea.tsx
"use client"

// @Diar Muradi Textarea v0.0
import * as React from "react"
import { tv, type VariantProps } from "@diarmuradi/ui/lib/index"
import { composeRenderProps, TextArea, TextField } from "react-aria-components"

const TEXTAREA_ROOT_NAME = "TextareaRoot"
const TEXTAREA_EL_NAME = "Textarea"
const TEXTAREA_RESIZE_HANDLE_NAME = "TextareaResizeHandle"
const TEXTAREA_COUNTER_NAME = "TextareaCounter"

export const textareaVariants = tv({
  slots: {
    root: [
      // base
      "group/textarea relative flex w-full flex-col rounded-lg bg-background pb-2.5",
      "transition duration-200 ease-out",
      // before ring
      "before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-border",
      "before:pointer-events-none before:rounded-[inherit]",
      "before:transition before:duration-200 before:ease-out",
      // hover
      "hover:[&:not(:focus-within)]:bg-muted/50",
      // disabled
      "has-[[disabled]]:pointer-events-none has-[[disabled]]:bg-muted/50 has-[[disabled]]:before:ring-transparent",
    ],
    textarea: [
      // base
      "block w-full resize-none text-sm text-foreground outline-none",
      "pointer-events-auto h-full min-h-[82px] bg-transparent pl-3 pr-2.5 pt-2.5",
      // placeholder
      "placeholder:select-none placeholder:text-muted-foreground placeholder:transition placeholder:duration-200 placeholder:ease-out",
      // hover placeholder
      "group-hover/textarea:placeholder:text-foreground/60",
      // focus
      "focus:outline-none",
      // focus placeholder
      "focus:placeholder:text-foreground/60",
      // disabled
      "disabled:text-muted-foreground disabled:placeholder:text-muted-foreground",
    ],
    resizeHandle: [
      // base
      "pointer-events-none size-3 cursor-s-resize",
    ],
    counter: [
      // base
      "text-xs text-muted-foreground",
      // disabled
      "group-has-[[disabled]]/textarea:text-muted-foreground",
    ],
  },
  variants: {
    hasError: {
      true: {
        root: [
          // base
          "before:ring-danger",
          // hover
          "hover:[&:not(:focus-within)]:before:ring-transparent",
          // focus
          "has-[textarea:focus-visible]:before:ring-danger",
          "has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-danger/10",
          "has-[textarea:focus-visible]:ring-offset-2 has-[textarea:focus-visible]:ring-offset-background",
        ],
        counter: "text-destructive",
      },
      false: {
        root: [
          // hover
          "hover:[&:not(:focus-within)]:before:ring-transparent",
          // focus
          "has-[textarea:focus-visible]:before:ring-primary",
          "has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-primary/10",
          "has-[textarea:focus-visible]:ring-offset-2 has-[textarea:focus-visible]:ring-offset-background",
        ],
      },
    },
  },
  defaultVariants: {
    hasError: false,
  },
})

type TextareaSharedProps = VariantProps<typeof textareaVariants>

const Textarea = React.forwardRef<
  HTMLTextAreaElement,
  React.TextareaHTMLAttributes<HTMLTextAreaElement> & TextareaSharedProps
>(({ className, hasError, disabled, ...rest }, forwardedRef) => {
  const { textarea } = textareaVariants({ hasError })

  return (
    <TextArea
      className={composeRenderProps(className, (className) =>
        textarea({ class: className })
      )}
      ref={forwardedRef}
      disabled={disabled}
      {...rest}
    />
  )
})
Textarea.displayName = TEXTAREA_EL_NAME

function ResizeHandle() {
  const { resizeHandle } = textareaVariants()

  return (
    <div className={resizeHandle()}>
      <svg
        width="12"
        height="12"
        viewBox="0 0 12 12"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path
          d="M9.11111 2L2 9.11111M10 6.44444L6.44444 10"
          className="stroke-muted-foreground"
        />
      </svg>
    </div>
  )
}
ResizeHandle.displayName = TEXTAREA_RESIZE_HANDLE_NAME

type TextareaRootProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> &
  TextareaSharedProps & {
    children?: React.ReactNode
    containerClassName?: string
  }

const TextareaRoot = React.forwardRef<HTMLTextAreaElement, TextareaRootProps>(
  (
    { containerClassName, children, hasError, className, ...rest },
    forwardedRef
  ) => {
    const { root } = textareaVariants({ hasError })

    return (
      <TextField
        className={composeRenderProps(className, (className) =>
          root({ class: containerClassName || className })
        )}
      >
        <div className="grid">
          <div className="pointer-events-none relative z-10 flex flex-col gap-2 [grid-area:1/1]">
            <Textarea ref={forwardedRef} hasError={hasError} {...rest} />
            <div className="pointer-events-none flex items-center justify-end gap-1.5 pr-2.5 pl-3">
              {children}
              <ResizeHandle />
            </div>
          </div>
          <div className="min-h-full resize-y overflow-hidden opacity-0 [grid-area:1/1]" />
        </div>
      </TextField>
    )
  }
)
TextareaRoot.displayName = TEXTAREA_ROOT_NAME

function CharCounter({
  current,
  max,
  className,
}: {
  current?: number
  max?: number
} & React.HTMLAttributes<HTMLSpanElement>) {
  const { counter } = textareaVariants()

  if (current === undefined || max === undefined) return null

  const isError = current > max

  return (
    <span
      className={counter({
        class: className,
        hasError: isError,
      })}
    >
      {current}/{max}
    </span>
  )
}
CharCounter.displayName = TEXTAREA_COUNTER_NAME

export { TextareaRoot as Root, CharCounter }

Usage

import * as Textarea from "@/registry/react-aria/ui/textarea"
 
export default function TextareaExample() {
  return (
    <Textarea.Root placeholder="Enter your message...">
      <Textarea.CharCounter current={value.length} max={100} />
    </Textarea.Root>
  )
}

Examples

Basic Textarea

A simple textarea with placeholder text.

<Textarea.Root placeholder="Enter your message..." />

With Character Counter

Display a character count with optional maximum limit.

<Textarea.Root
  placeholder="Type something... (max 100 characters)"
  maxLength={100}
>
  <Textarea.CharCounter current={value.length} max={100} />
</Textarea.Root>

With Error State

Show error styling when validation fails.

<Textarea.Root hasError placeholder="This textarea has an error state..." />

Disabled State

A disabled textarea that cannot be interacted with.

<Textarea.Root isDisabled placeholder="This textarea is disabled..." />

Readonly State

A readonly textarea that displays content but cannot be edited.

<Textarea.Root
  isReadOnly
  value="This is readonly content that cannot be edited"
/>

With Label and Hint

Include a label and hint text for better accessibility.

import * as Hint from "@/registry/react-aria/ui/hint"
import * as Label from "@/registry/react-aria/ui/label"
 
;<div className="space-y-2">
  <Label.Root htmlFor="description">Description</Label.Root>
  <Textarea.Root id="description" placeholder="Enter a description..." />
  <Hint.Root>Provide a detailed description of your project.</Hint.Root>
</div>

Size Variants

Choose from different size options.

<Textarea.Root size="sm" placeholder="Small textarea..." />
<Textarea.Root size="md" placeholder="Medium textarea..." />
<Textarea.Root size="lg" placeholder="Large textarea..." />

Resize Controls

Control how the textarea can be resized.

<Textarea.Root resize="none" placeholder="Cannot be resized..." />
<Textarea.Root resize="vertical" placeholder="Vertical resize only..." />
<Textarea.Root resize="horizontal" placeholder="Horizontal resize only..." />
<Textarea.Root resize="both" placeholder="Both directions..." />

Resize Handle Control

Show or hide the resize handle.

<Textarea.Root showResizeHandle placeholder="Shows resize handle..." />
<Textarea.Root showResizeHandle={false} placeholder="No resize handle..." />

Auto Resize

Automatically adjust height based on content.

<Textarea.Root autoResize placeholder="Type to see auto resize..." />
<Textarea.Root autoResize={{ minRows: 2, maxRows: 6 }} placeholder="Custom min/max rows..." />