'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 라이브러리와 연동 가능
*/
import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
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
TextareaHTMLAttributesHTMLTextAreaElement,
'value' 'defaultValue' 'onChange'
{
/**
* 제어 모드일 때의 값 (Controlled)
* - 실무: react-hook-form, useState 등으로 값 제어 시 사용
*/
value?: string;
/**
* 비제어 모드일 때의 초기값 (Uncontrolled)
* - 실무: ref로만 접근하고 초기값만 설정할 때 사용
*/
defaultValue?: string;
/**
* 값 변경 시 호출
* - 실무: 검증, 글자 수 체크, API 연동 등
*/
onChange?: (value: string, e: ChangeEventHTMLTextAreaElement) = void;
/**
* 라벨 텍스트
* - 실무: 접근성을 위해 label과 textarea를 htmlFor/id로 연결
* - 필수 prop 아님: label 없이도 사용 가능 (예: 모달 내 간단 입력)
*/
label?: string;
/**
* 에러 메시지
* - 실무: 폼 검증 실패 시 에러 메시지 표시 + border-red 등 스타일
* - 에러가 있으면 helperText 대신 표시됨
*/
error?: string;
/**
* 보조 설명 텍스트 (helperText / description)
* - 실무: 입력 가이드, 예시, 제한 사항 안내
* - error가 있으면 표시되지 않음
*/
helperText?: string;
/**
* 최대 글자 수
* - 실무: 게시글 본문, 상품 설명 등 길이 제한 필요 시
* - showCount와 함께 사용하면 current/max 표시
*/
maxLength?: number;
/**
* 글자 수 표시 여부
* - maxLength가 있을 때 기본값 true (실무에서 대부분 함께 사용)
* - false면 maxLength만 적용하고 표시는 안 함
*/
showCount?: boolean;
/**
* 초기/최소 줄 수
* - 기본값 3: 게시글 작성 등에서 적당한 기본 높이
*/
rows?: number;
/**
* 자동 높이 조절 시 최소 줄 수 (autoResize true일 때)
*/
minRows?: number;
/**
* 자동 높이 조절 시 최대 줄 수 (autoResize true일 때)
* - 실무: 무한 확장 방지, 레이아웃 깨짐 방지
*/
maxRows?: number;
/**
* 자동 높이 조절
* - 실무: SNS 댓글, 게시글 작성 등 내용에 따라 높이 확장
* - 입력 시 스크롤 없이 내용 전부 보이게 할 때 유용
*/
autoResize?: boolean;
/**
* resize 방향 제어
* - none: 고정 (실무에서 가장 많이 사용, 레이아웃 일관성)
* - vertical: 세로만
* - both: 가로+세로
* - horizontal: 가로만 (거의 사용 안 함)
*/
resize?: TextareaResize;
/**
* 전체 wrapper div에 적용할 className
* - 실무: margin, 전체 배치 등
*/
wrapperClassName?: string;
/**
* textarea 요소에 적용할 className
* - 실무: 기존 스타일 유지하면서 세부 스타일만 오버라이드
*/
textareaClassName?: string;
/**
* 전체 wrapper div에 적용할 style
*/
wrapperStyle?: CSSProperties;
/**
* textarea 요소에 적용할 style
*/
textareaStyle?: CSSProperties;
/**
* fullWidth 여부
* - 기본값 true: Input/Select와 일관된 기본 동작
*/
fullWidth?: boolean;
}
const resizeClassMap: RecordTextareaResize, string = {
none: 'resize-none',
vertical: 'resize-y',
both: 'resize',
horizontal: 'resize-x',
};
export const Textarea = forwardRefHTMLTextAreaElement, 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 = useRefHTMLTextAreaElement 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;
}
},
[ref]
);
// 현재 표시할 값 (controlled vs uncontrolled)
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = useState(defaultValue ?? '');
const displayValue = isControlled ? (value ?? '') : internalValue;
const currentLength = displayValue.length;
// controlled 동기화
useEffect(() = {
if (isControlled) return;
setInternalValue(defaultValue ?? '');
}, [defaultValue, isControlled]);
const handleChange = useCallback(
(e: ChangeEventHTMLTextAreaElement) = {
const next = e.target.value;
if (!isControlled) {
setInternalValue(next);
}
onChange?.(next, e);
},
[isControlled, onChange]
);
const handleFocus = useCallback(
(e: FocusEventHTMLTextAreaElement) = {
onFocus?.(e);
},
[onFocus]
);
const handleBlur = useCallback(
(e: FocusEventHTMLTextAreaElement) = {
onBlur?.(e);
},
[onBlur]
);
// autoResize: 스크롤 높이에 맞춰 textarea 높이 조절
const syncHeight = useCallback(() = {
const el = internalRef.current;
if (!el !autoResize) return;
el.style.height = 'auto';
const lineHeight = parseInt(
getComputedStyle(el).lineHeight '20',
10
);
const paddingY =
parseInt(getComputedStyle(el).paddingTop '0', 10) +
parseInt(getComputedStyle(el).paddingBottom '0', 10);
const borderY =
parseInt(getComputedStyle(el).borderTopWidth '0', 10) +
parseInt(getComputedStyle(el).borderBottomWidth '0', 10);
const minHeight = minRows * lineHeight + paddingY + borderY;
const maxHeight = maxRows * lineHeight + paddingY + borderY;
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]);
useEffect(() = {
syncHeight();
}, [displayValue, syncHeight]);
// 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);
return (
div
className={cn('space-y-2', fullWidth && 'w-full', wrapperClassName)}
style={wrapperStyle}
{label && (
label
htmlFor={textareaId}
className="block text-label4 font-medium"
{label}
/label
)}
div className="relative"
textarea
ref={setRef}
id={textareaId}
value={displayValue}
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-lg border bg-white px-4 py-3',
'focus:outline-none focus:ring-1 focus:border-black',
'disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-50',
'read-only:cursor-default read-only:bg-gray-50',
hasError
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 focus:ring-black',
autoResize ? 'resize-none overflow-hidden' : resizeClassMap[resize],
className,
textareaClassName
)}
style={{ ...style, ...textareaStyle }}
{...rest}
/
/div
{/* 글자 수 표시 (maxLength 있을 때) */}
{showCount && maxLength != null && (
p
id={countId}
className={cn(
'text-right text-body3',
currentLength = maxLength ? 'text-red-600' : 'text-zinc-500'
)}
{currentLength} / {maxLength}
/p
)}
{error && (
p id={errorId} className="text-sm text-red-600"
{error}
/p
)}
{helperText && !error && (
p id={helperId} className="text-sm"
{helperText}
/p
)}
/div
);
}
);
Textarea.displayName = 'Textarea';
1. 왜 Textarea를 따로 만들었는지
관리자 페이지 소개역량프로젝트, 약관 본문, 유튜브 설명, 연락처 문의 내용처럼 여러 줄 입력이 필요한 곳이 많았는데, 기본 textarea만 쓰면 라벨에러도움말글자 수 표시를 매번 반복하고, 높이 제어(고정/리사이즈/자동 확장)도 페이지마다 달랐습니다. 그래서 InputCheckbox와 같은 패턴으로 라벨 + textarea + 에러/도움말/글자 수를 한 덩어리로 묶고, 제어/비제어autoResizeresizemaxLength까지 한 컴포넌트에서 처리하는 공용 Textarea를 만들기로 했습니다.
2. 컴포넌트 구성과 설계 방향
제어 vs 비제어value가 넘어오면 제어 모드, 아니면 defaultValue와 내부 internalValue로 비제어 모드로 동작하게 했습니다. 제어일 때는 displayValue = value ?? '', 비제어일 때는 internalValue를 쓰고, useEffect에서 defaultValue 변경 시에만 internalValue를 갱신해 비제어에서 불필요한 리셋이 나지 않게 했습니다. onChange는 (value: string, e) 형태로 두어서 부모는 문자열만 받아도 되고, react-hook-form 같은 라이브러리와 연동할 때는 래퍼에서 e만 넘기면 됩니다.
ref와 autoResize
autoResize는 DOM의 scrollHeight를 읽어서 높이를 바꿔야 하므로 내부에서 textarea ref가 필요합니다. 그런데 부모도 ref를 넘길 수 있어서, useImperativeHandle(ref, () = internalRef.current)와 setRef 콜백에서 internalRef에 넣은 뒤 함수 ref/객체 ref 모두 전달하도록 했습니다. 이렇게 해서 react-hook-form의 ref와 autoResize용 내부 ref를 동시에 만족시켰습니다.
syncHeight 로직autoResize가 true일 때만 syncHeight가 동작합니다. 매번 height = 'auto'로 초기화한 뒤 scrollHeight와 lineHeightpaddingborder를 구해, minRowsmaxRows에 맞춰 min~max 사이로 높이를 넣고, 그보다 크면 overflow-y: auto로 스크롤되게 했습니다. displayValue가 바뀔 때마다 useEffect에서 syncHeight()를 호출해 입력 시마다 높이가 맞춰지게 했습니다.
접근성
에러도움말글자 수 영역에 각각 id를 두고, aria-describedby에 에러 있으면 error id, 없으면 helper id, showCountmaxLength 있으면 count id를 공백으로 이어 붙였습니다. 그래서 스크린 리더가 에러 메시지도움말글자 수를 보조 설명으로 읽을 수 있습니다. aria-invalid는 error가 있을 때만 true로 두었습니다.
스타일 분리
래퍼와 textarea를 나눠서, 레이아웃간격은 wrapperClassNamewrapperStyle, 입력창만의 스타일은 textareaClassNametextareaStyleclassName으로 오버라이드할 수 있게 했습니다. Input과 맞추기 위해 기본은 fullWidth = true로 두었습니다.
3. 프롭스가 필요한 이유
value, defaultValue, onChange
제어 모드에서는 value로 값을 넘기고 onChange(next, e)로 갱신하게 했습니다. 비제어에서는 defaultValue만 주고 ref로 나중에 값을 읽을 수 있게 했습니다. onChange의 첫 인자를 문자열로 둔 이유는 부모에서 setState(next)만 쓰면 되게 하려는 것이고, 기존 TextareaHTMLAttributes의 value/defaultValue/onChange는 Omit 해서 우리 시그니처만 쓰도록 했습니다.
label
Input과 같이 필드 이름을 붙이고 접근성(htmlFor/id)을 맞추려고 넣었습니다. 없으면 textarea만 렌더링해서 모달 안 짧은 입력 등에도 쓸 수 있게 했습니다.
error, helperText
폼 검증 실패 시 같은 위치에 에러를 보여주고, 입력 가이드는 helperText로 두려고 넣었습니다. error가 있으면 helper는 숨기고 에러만 aria-describedby에 연결해 두었습니다.
maxLength, showCount
게시글 본문상품 설명처럼 글자 수 제한이 있을 때 maxLength를 넘기면 네이티브 제한과 함께 현재/최대 표시를 하려고 넣었습니다. showCount는 maxLength가 있을 때 기본 true로 두어서 대부분 제한과 표시를 같이 쓰게 했고, false면 maxLength만 적용하고 숫자는 안 보이게 했습니다. 글자 수 영역에도 id를 달아 aria-describedby에 넣어 접근성에 포함시켰습니다.
rows, minRows, maxRows
고정 높이를 주고 싶을 때는 rows만 쓰면 되고(기본 3), autoResize일 때는 minRows로 최소 줄 수, maxRows로 최대 줄 수를 제한해 무한 확장과 레이아웃 깨짐을 막았습니다.
autoResize
댓글짧은 본문처럼 내용이 늘어나면 높이만 따라가고 스크롤은 최대 높이에서만 나오게 하려고 넣었습니다. true면 resize-none overflow-hidden으로 두고 syncHeight로 높이를 조절하고, false면 resize prop대로 동작하게 했습니다.
resize
레이아웃을 맞추고 싶을 때는 대부분 none, 세로만 늘리게 하려면 vertical, 가로세로 모두 사용자가 조절하게 하려면 both를 쓰도록 했습니다. resizeClassMap으로 Tailwind의 resize 클래스와 매핑해 두었습니다.
wrapperClassName, textareaClassName, wrapperStyle, textareaStyle
래퍼에는 margin전체 배치, textarea에는 패딩폰트 등만 덮어쓰고 싶을 때를 위해 나눠 두었습니다. className은 textarea에만 적용되게 해서 Input과 같은 사용감을 유지했습니다.
fullWidth
InputSelect와 맞추기 위해 기본 true로 두어, 폼 안에서 기본적으로 한 줄을 채우게 했습니다. false면 필요한 만큼만 차지하게 할 수 있습니다.
4. 사용 용도와 쓰는 방식
관리자 폼: Admin 페이지 빌더의 소개역량주요 프로젝트, 약관 본문, 유튜브 설명 등에
label+value/onChange로 제어 모드로 쓰고, 필요하면maxLengthshowCounthelperText를 붙입니다.연락처: 문의 내용 필드에
label="문의 내용",required,value/onChange로 사용합니다.쇼케이스: 기본에러비활성읽기전용도움말, maxLength+글자 수, autoResize, resize별 예시를 같은 컴포넌트로 보여줄 때 사용하고, 제어/비제어 예시로
value/onChangevsdefaultValue+ref 패턴을 설명할 때 씁니다.
5. 정리
Textarea는 제어/비제어 모두 지원하고, 라벨에러도움말글자 수를 Input과 같은 패턴으로 처리하며, autoResizeresizemaxLength로 높이와 길이 제한까지 한 곳에서 하자는 목적으로 만들었습니다. value/defaultValue/onChange는 제어와 폼 연동을 위해, labelerrorhelperText는 폼 UX와 접근성을 위해, maxLengthshowCount는 길이 제한과 표시를 위해, rowsminRowsmaxRowsautoResizeresize는 높이 제어를 위해, wrapperClassNametextareaClassNamefullWidth는 레이아웃스타일 조정을 위해 넣었습니다. 관리자 페이지약관유튜브연락처 문의 등 여러 줄 입력이 필요한 폼에서 공통으로 쓰고 있습니다.