V1.0

Base Components

Textarea

Renders a textarea with custom resize handle and character length display.

78/200

Installation

Create a textarea.tsx file and paste the following code into it.

/components/ui/textarea.tsx
// AlignUI Textarea v0.0.0
 
import * as React from 'react';
 
import { cnExt } from '@/utils/cn';
 
const TEXTAREA_ROOT_NAME = 'TextareaRoot';
const TEXTAREA_NAME = 'Textarea';
const TEXTAREA_RESIZE_HANDLE_NAME = 'TextareaResizeHandle';
const TEXTAREA_COUNTER_NAME = 'TextareaCounter';
 
const Textarea = React.forwardRef<
  HTMLTextAreaElement,
  React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
    hasError?: boolean;
    simple?: boolean;
  }
>(({ className, hasError, simple, disabled, ...rest }, forwardedRef) => {
  return (
    <textarea
      className={cnExt(
        [
          // base
          'block w-full resize-none text-paragraph-sm text-text-strong-950 outline-none',
          !simple && [
            'pointer-events-auto h-full min-h-[82px] bg-transparent pl-3 pr-2.5 pt-2.5',
          ],
          simple && [
            'min-h-28 rounded-xl bg-bg-white-0 px-3 py-2.5 shadow-regular-xs',
            'ring-1 ring-inset ring-stroke-soft-200',
            'transition duration-200 ease-out',
            // hover
            'hover:[&:not(:focus)]:bg-bg-weak-50',
            !hasError && [
              // hover
              'hover:[&:not(:focus)]:ring-transparent',
              // focus
              'focus:shadow-button-important-focus focus:ring-stroke-strong-950',
            ],
            hasError && [
              // base
              'ring-error-base',
              // focus
              'focus:shadow-button-error-focus focus:ring-error-base',
            ],
            disabled && ['bg-bg-weak-50 ring-transparent'],
          ],
          !disabled && [
            // placeholder
            'placeholder:select-none placeholder:text-text-soft-400 placeholder:transition placeholder:duration-200 placeholder:ease-out',
            // hover placeholder
            'group-hover/textarea:placeholder:text-text-sub-600',
            // focus
            'focus:outline-none',
            // focus placeholder
            'focus:placeholder:text-text-sub-600',
          ],
          disabled && [
            // disabled
            'text-text-disabled-300 placeholder:text-text-disabled-300',
          ],
        ],
        className,
      )}
      ref={forwardedRef}
      disabled={disabled}
      {...rest}
    />
  );
});
Textarea.displayName = TEXTAREA_NAME;
 
function ResizeHandle() {
  return (
    <div className='pointer-events-none size-3 cursor-s-resize'>
      <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-text-soft-400'
        />
      </svg>
    </div>
  );
}
ResizeHandle.displayName = TEXTAREA_RESIZE_HANDLE_NAME;
 
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> &
  (
    | {
        simple: true;
        children?: never;
        containerClassName?: never;
        hasError?: boolean;
      }
    | {
        simple?: false;
        children?: React.ReactNode;
        containerClassName?: string;
        hasError?: boolean;
      }
  );
 
const TextareaRoot = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
  (
    { containerClassName, children, hasError, simple, ...rest },
    forwardedRef,
  ) => {
    if (simple) {
      return (
        <Textarea ref={forwardedRef} simple hasError={hasError} {...rest} />
      );
    }
 
    return (
      <div
        className={cnExt(
          [
            // base
            'group/textarea relative flex w-full flex-col rounded-xl bg-bg-white-0 pb-2.5 shadow-regular-xs',
            'ring-1 ring-inset ring-stroke-soft-200',
            'transition duration-200 ease-out',
            // hover
            'hover:[&:not(:focus-within)]:bg-bg-weak-50',
            // disabled
            'has-[[disabled]]:pointer-events-none has-[[disabled]]:bg-bg-weak-50 has-[[disabled]]:ring-transparent',
          ],
          !hasError && [
            // hover
            'hover:[&:not(:focus-within)]:ring-transparent',
            // focus
            'focus-within:shadow-button-important-focus focus-within:ring-stroke-strong-950',
          ],
          hasError && [
            // base
            'ring-error-base',
            // focus
            'focus-within:shadow-button-error-focus focus-within:ring-error-base',
          ],
          containerClassName,
        )}
      >
        <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 pl-3 pr-2.5'>
              {children}
              <ResizeHandle />
            </div>
          </div>
          <div className='min-h-full resize-y overflow-hidden opacity-0 [grid-area:1/1]' />
        </div>
      </div>
    );
  },
);
TextareaRoot.displayName = TEXTAREA_ROOT_NAME;
 
function CharCounter({
  current,
  max,
  className,
}: {
  current?: number;
  max?: number;
} & React.HTMLAttributes<HTMLSpanElement>) {
  if (current === undefined || max === undefined) return null;
 
  const isError = current > max;
 
  return (
    <span
      className={cnExt(
        'text-subheading-2xs text-text-soft-400',
        // disabled
        'group-has-[[disabled]]/textarea:text-text-disabled-300',
        {
          'text-error-base': isError,
        },
        className,
      )}
    >
      {current}/{max}
    </span>
  );
}
CharCounter.displayName = TEXTAREA_COUNTER_NAME;
 
export { TextareaRoot as Root, CharCounter };

Update the import paths to match your project setup.

Examples

Interactive Char Counter

0/200

Has Error

78/200

With Label and Hint

78/200
This is a hint text to help user.

Disabled

78/200

Simple

A single textarea element without a custom resize handle or character counter.

Simple: Resizable

Simple textarea with built-in resize handle.

Simple: hasError

Simple textarea with error styles.

Simple: Disabled

Simple textarea with disabled styles.

API Reference

Textarea.Root

This component is based on the <textarea> element and supports all of its props. And adds:

PropTypeDefault
simple
boolean
hasError
boolean
containerClassName
string
containerClassName
never
children
React.ReactNode
children
never

Textarea.CharCounter

Displays the current and maximum number of characters in a textarea.

This component is based on the <span> element and supports all of its props. And adds:

PropTypeDefault
current
number
max
number
© 2024 AlignUI Design System. All rights reserved.