Textarea
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
프로젝트의 핵심 기능에 대해 좀 더 자세히 알고 싶습니다. 가능하다면 데모 영상이나 문서를 공유해 주실 수 있을까요?
72 / 1000
Textarea
관리자 페이지, 회원가입, 게시글 작성 등에서 반복 사용 가능한 범용 Textarea 컴포넌트입니다.
label, error, helperText, maxLength+글자 수 표시, autoResize, resize 제어, forwardRef 등 실무에서 필요한 기능을 지원합니다.
기본 Textarea
label, placeholder가 있는 기본 사용 예시
Source Code
Live Preview
상태별 (error, disabled, readOnly, helperText)
에러, 비활성화, 읽기전용, 도움말 텍스트
Error
이 필드는 필수입니다.
Live Preview
Disabled
Live Preview
Read Only
Live Preview
Helper Text
100자 이내로 입력해주세요.
Live Preview
maxLength + 글자 수 표시
게시글 본문, 상품 설명 등 길이 제한 시 사용
Source Code
0 / 500
게시글에 공개될 본문 내용입니다.
Live Preview
자동 높이 조절 (autoResize)
입력 내용에 따라 높이가 자동으로 늘어납니다. minRows, maxRows로 범위 제한
Source Code
Live Preview
resize 제어
resize: none(기본) | vertical | both
None
Live Preview
Vertical
Live Preview
Both
Live Preview
Controlled / Uncontrolled
value+onChange로 제어 모드, defaultValue로 비제어 모드
Source Code
현재 길이: 0자
Live Preview
폼 조합 예시 (회원가입 / 게시글 작성)
Input, Select, Textarea를 함께 사용하는 실무 폼
Source Code
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';