Textarea

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
프로젝트의 핵심 기능에 대해 좀 더 자세히 알고 싶습니다. 가능하다면 데모 영상이나 문서를 공유해 주실 수 있을까요?
72 / 1000

Textarea

관리자 페이지, 회원가입, 게시글 작성 등에서 반복 사용 가능한 범용 Textarea 컴포넌트입니다.
label, error, helperText, maxLength+글자 수 표시, autoResize, resize 제어, forwardRef 등 실무에서 필요한 기능을 지원합니다.

기본 Textarea

label, placeholder가 있는 기본 사용 예시

Source Code
          <Textarea
            label="메모"
            placeholder="메모를 입력하세요"
            rows={3}
          />
        
Live Preview

상태별 (error, disabled, readOnly, helperText)

에러, 비활성화, 읽기전용, 도움말 텍스트

Error
<Textarea label="에러" error="이 필드는 필수입니다." />

이 필드는 필수입니다.

Live Preview
Disabled
<Textarea label="비활성화" disabled placeholder="입력 불가" />
Live Preview
Read Only
<Textarea label="읽기전용" readOnly value="수정할 수 없는 내용" />
Live Preview
Helper Text
<Textarea label="도움말" helperText="100자 이내로 입력해주세요." />

100자 이내로 입력해주세요.

Live Preview

maxLength + 글자 수 표시

게시글 본문, 상품 설명 등 길이 제한 시 사용

Source Code
          <Textarea
            label="게시글 본문"
            placeholder="내용을 입력하세요"
            maxLength={500}
            showCount
            rows={5}
            value={postValue}
            onChange={setPostValue}
          />
        

0 / 500

게시글에 공개될 본문 내용입니다.

Live Preview

자동 높이 조절 (autoResize)

입력 내용에 따라 높이가 자동으로 늘어납니다. minRows, maxRows로 범위 제한

Source Code
          <Textarea
            label="댓글"
            placeholder="댓글을 입력하세요"
            autoResize
            minRows={2}
            maxRows={8}
            value={bioValue}
            onChange={setBioValue}
          />
        
Live Preview

resize 제어

resize: none(기본) | vertical | both

None
<Textarea label="고정" resize="none" rows={3} />
Live Preview
Vertical
<Textarea label="세로만" resize="vertical" rows={3} />
Live Preview
Both
<Textarea label="가로+세로" resize="both" rows={3} />
Live Preview

Controlled / Uncontrolled

value+onChange로 제어 모드, defaultValue로 비제어 모드

Source Code
          // Controlled
          const [value, setValue] = useState('');
          <Textarea value={value} onChange={setValue} />

          // Uncontrolled
          <Textarea defaultValue="초기값" ref={textareaRef} />
        

현재 길이: 0자

Live Preview

폼 조합 예시 (회원가입 / 게시글 작성)

Input, Select, Textarea를 함께 사용하는 실무 폼

Source Code
          <form className="space-y-4 max-w-xl">
            <Input label="제목" placeholder="제목을 입력하세요" />
            <Select label="카테고리" options={options} placeholder="선택" />
            <Textarea
              label="본문"
              placeholder="내용을 입력하세요"
              maxLength={1000}
              showCount
              autoResize
              minRows={4}
              maxRows={12}
            />
          </form>
        
카테고리

0 / 1000

마크다운을 지원합니다.

Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Textareatypescript
'use client';

/**
 * Textarea 컴포넌트
 *
 * 실무 서비스(관리자 페이지, 회원가입, 게시글 작성 등)에서 반복 사용 가능한 범용 Textarea입니다.
 * - Controlled/Uncontrolled 모두 대응 (value / defaultValue)
 * - label, error, helperText로 폼 UX 통일
 * - maxLength + 글자 수 표시 (current/max)
 * - rows, autoResize로 높이 제어
 * - resize (none | vertical | both) 제어
 * - forwardRef로 react-hook-form 등 form 라이브러리와 연동 가능
 *
 * [성능 설계]
 * - Uncontrolled 모드: 글자 수 카운터를 countRef로 직접 DOM 업데이트 → 리렌더 없음
 * - Controlled 모드: 부모가 value를 전달 → 부모 리렌더 시에만 렌더
 * - autoResize: getComputedStyle은 마운트 시 1회만 캐싱, height는 'auto' reset 없이 scrollHeight 비교
 */

import {
  forwardRef,
  useCallback,
  useEffect,
  useId,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  type ChangeEvent,
  type CSSProperties,
  type FocusEvent,
  type TextareaHTMLAttributes,
} from 'react';
import { cn } from '@/lib/cn';

export type TextareaResize = 'none' | 'vertical' | 'both' | 'horizontal';

export interface TextareaProps
  extends Omit<
    TextareaHTMLAttributes<HTMLTextAreaElement>,
    'value' | 'defaultValue' | 'onChange'
  > {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string, e: ChangeEvent<HTMLTextAreaElement>) => void;
  label?: string;
  error?: string;
  helperText?: string;
  maxLength?: number;
  showCount?: boolean;
  rows?: number;
  minRows?: number;
  maxRows?: number;
  autoResize?: boolean;
  resize?: TextareaResize;
  wrapperClassName?: string;
  textareaClassName?: string;
  wrapperStyle?: CSSProperties;
  textareaStyle?: CSSProperties;
  fullWidth?: boolean;
}

const resizeClassMap: Record<TextareaResize, string> = {
  none: 'resize-none',
  vertical: 'resize-y',
  both: 'resize',
  horizontal: 'resize-x',
};

export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
  (
    {
      value,
      defaultValue,
      onChange,
      onFocus,
      onBlur,
      label,
      error,
      helperText,
      maxLength,
      showCount = maxLength != null,
      rows = 3,
      minRows = 2,
      maxRows = 12,
      autoResize = false,
      resize = 'none',
      disabled,
      readOnly,
      placeholder,
      id: idProp,
      className,
      wrapperClassName,
      textareaClassName,
      style,
      wrapperStyle,
      textareaStyle,
      fullWidth = true,
      ...rest
    },
    ref
  ) => {
    const generatedId = useId();
    const textareaId =
      idProp ?? `textarea-${generatedId.replace(/:/g, '-')}`;
    const internalRef = useRef<HTMLTextAreaElement | null>(null);

    // 글자 수 카운터 DOM 직접 업데이트용 ref
    const countRef = useRef<HTMLParagraphElement | null>(null);

    // ref 병합: 외부 ref + 내부 ref (autoResize용)
    useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
    const setRef = useCallback(
      (el: HTMLTextAreaElement | null) => {
        internalRef.current = el;
        if (typeof ref === 'function') {
          ref(el);
        } else if (ref) {
          ref.current = el;
        }
      },
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [ref]
    );

    // ─────────────────────────────────────────────────────────────────
    // Controlled vs Uncontrolled 판별
    //
    // Controlled:   value prop을 받음 → 부모가 상태 관리 → 이 컴포넌트는 리렌더만
    // Uncontrolled: value 없음 → textarea DOM이 값 관리 → 이 컴포넌트는 카운터만 DOM 직접 업데이트
    //               → setInternalValue 같은 state 없음 → 키입력 시 리렌더 없음
    // ─────────────────────────────────────────────────────────────────
    const isControlled = value !== undefined;

    // 카운터 표시를 직접 DOM에 업데이트
    // controlled: value.length (렌더에서 처리), uncontrolled: DOM 직접
    const updateCountDom = useCallback(
      (len: number) => {
        if (!countRef.current || !showCount || maxLength == null) return;
        countRef.current.textContent = `${len} / ${maxLength}`;
        // 한도 초과 전환 시에만 className 변경 (불필요한 스타일 재계산 방지)
        const isOver = len >= maxLength;
        const wantsRed = countRef.current.classList.contains('text-rose-600');
        if (isOver && !wantsRed) {
          countRef.current.classList.remove('text-slate-500');
          countRef.current.classList.add('text-rose-600');
        } else if (!isOver && wantsRed) {
          countRef.current.classList.remove('text-rose-600');
          countRef.current.classList.add('text-slate-500');
        }
      },
      [showCount, maxLength]
    );

    // ── autoResize ────────────────────────────────────────────────────
    // getComputedStyle: 레이아웃 강제 트리거로 매우 느림.
    // 마운트 시 1회만 읽어 캐시 → 이후 scrollHeight 비교만으로 높이 결정.
    const cachedMetrics = useRef<{
      lineHeight: number;
      paddingY: number;
      borderY: number;
    } | null>(null);

    const readMetrics = useCallback(() => {
      const el = internalRef.current;
      if (!el) return null;
      const cs = getComputedStyle(el);
      return {
        lineHeight: parseInt(cs.lineHeight || '20', 10),
        paddingY:
          parseInt(cs.paddingTop || '0', 10) +
          parseInt(cs.paddingBottom || '0', 10),
        borderY:
          parseInt(cs.borderTopWidth || '0', 10) +
          parseInt(cs.borderBottomWidth || '0', 10),
      };
    }, []);

    useEffect(() => {
      if (!autoResize) return;
      cachedMetrics.current = readMetrics();
    }, [autoResize, readMetrics]);

    const syncHeight = useCallback(() => {
      const el = internalRef.current;
      if (!el || !autoResize) return;

      if (!cachedMetrics.current) {
        cachedMetrics.current = readMetrics();
      }
      const { lineHeight, paddingY, borderY } = cachedMetrics.current ?? {
        lineHeight: 20,
        paddingY: 0,
        borderY: 0,
      };

      const minHeight = minRows * lineHeight + paddingY + borderY;
      const maxHeight = maxRows * lineHeight + paddingY + borderY;

      // height를 auto로 리셋해야 scrollHeight가 올바르게 나옴
      el.style.height = 'auto';
      const scrollHeight = el.scrollHeight + borderY;

      const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
      el.style.height = `${newHeight}px`;
      el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
    }, [autoResize, minRows, maxRows, readMetrics]);

    // Controlled 모드 전용 height 동기화 (value prop이 바뀔 때)
    // Uncontrolled 모드는 handleChange에서 syncHeight 직접 호출
    useLayoutEffect(() => {
      if (!autoResize || !isControlled) return;
      syncHeight();
    }, [autoResize, isControlled, value, syncHeight]);

    // 초기 마운트 시 height 동기화 (Uncontrolled + autoResize)
    useLayoutEffect(() => {
      if (!autoResize || isControlled) return;
      syncHeight();
      // 마운트 1회만
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    const handleChange = useCallback(
      (e: ChangeEvent<HTMLTextAreaElement>) => {
        const next = e.target.value;

        if (!isControlled) {
          // ★ 핵심: Uncontrolled 모드에서는 setState 없음
          //   - 카운터: countRef 직접 DOM 업데이트 → 리렌더 없음
          //   - autoResize: syncHeight 직접 호출 (useLayoutEffect 불필요)
          updateCountDom(next.length);
          if (autoResize) syncHeight();
        }

        onChange?.(next, e);
      },
      [isControlled, onChange, updateCountDom, autoResize, syncHeight]
    );

    const handleFocus = useCallback(
      (e: FocusEvent<HTMLTextAreaElement>) => {
        onFocus?.(e);
      },
      [onFocus]
    );

    const handleBlur = useCallback(
      (e: FocusEvent<HTMLTextAreaElement>) => {
        onBlur?.(e);
      },
      [onBlur]
    );

    // aria-describedby: error, helperText, count를 연결
    const errorId = `${textareaId}-error`;
    const helperId = `${textareaId}-helper`;
    const countId = `${textareaId}-count`;
    const describedBy =
      [
        error ? errorId : null,
        helperText && !error ? helperId : null,
        showCount && maxLength ? countId : null,
      ]
        .filter(Boolean)
        .join(' ') || undefined;

    const hasError = Boolean(error);

    // Controlled 모드: value.length를 렌더에서 계산
    // Uncontrolled 모드: defaultValue 길이로 초기값만 표시 (이후는 DOM 직접 업데이트)
    const initialCount = isControlled
      ? (value?.length ?? 0)
      : (defaultValue?.length ?? 0);

    return (
      <div
        className={cn('space-y-1.5', fullWidth && 'w-full', wrapperClassName)}
        style={wrapperStyle}
      >
        {label && (
          <label
            htmlFor={textareaId}
            className="block text-xs font-semibold text-slate-700"
          >
            {label}
          </label>
        )}
        <div className="relative">
          <textarea
            ref={setRef}
            id={textareaId}
            // Controlled: value prop 전달 / Uncontrolled: defaultValue만 전달 (DOM이 값 관리)
            {...(isControlled
              ? { value: value ?? '' }
              : { defaultValue: defaultValue ?? '' })}
            onChange={handleChange}
            onFocus={handleFocus}
            onBlur={handleBlur}
            disabled={disabled}
            readOnly={readOnly}
            placeholder={placeholder}
            maxLength={maxLength}
            rows={autoResize ? minRows : rows}
            aria-invalid={hasError ? 'true' : 'false'}
            aria-describedby={describedBy}
            className={cn(
              'w-full rounded-xl border bg-white px-4 py-3 text-sm text-slate-900 shadow-sm transition-colors',
              'placeholder:text-slate-400',
              'focus:outline-none',
              'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-60',
              'read-only:cursor-default read-only:bg-slate-50',
              hasError
                ? 'border-rose-400 focus:border-rose-500 focus:ring-2 focus:ring-rose-500/20'
                : 'border-slate-300 hover:border-slate-400 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20',
              autoResize ? 'resize-none overflow-hidden' : resizeClassMap[resize],
              className,
              textareaClassName
            )}
            style={{ ...style, ...textareaStyle }}
            {...rest}
          />
        </div>

        {/* 글자 수 표시 */}
        {showCount && maxLength != null && (
          <p
            ref={countRef}
            id={countId}
            // Controlled: value.length로 React가 렌더
            // Uncontrolled: initialCount만 초기 렌더, 이후 DOM 직접 업데이트
            className={cn(
              'text-right text-xs tabular-nums',
              initialCount >= maxLength ? 'text-rose-600 font-medium' : 'text-slate-500'
            )}
          >
            {isControlled
              ? `${initialCount} / ${maxLength}`
              : `${initialCount} / ${maxLength}`}
          </p>
        )}

        {error && (
          <p id={errorId} className="text-xs font-medium text-rose-600">
            {error}
          </p>
        )}
        {helperText && !error && (
          <p id={helperId} className="text-xs text-slate-500">
            {helperText}
          </p>
        )}
      </div>
    );
  }
);

Textarea.displayName = 'Textarea';