React 타이핑 효과 컴포넌트 만들기 - HTML 태그와 이모지 완벽 지원
React에서 타이핑 효과를 구현하는 컴포넌트를 만들어봅니다. ReactNode children 지원, 복잡한 이모지 처리, 일시정지/재개 기능 등 다양한 기능을 포함한 완성도 높은 컴포넌트를 구현합니다.
타이핑 효과는 웹사이트에 생동감을 더하고 사용자의 주의를 끌 수 있는 효과적인 방법입니다. 이번 포스트에서는 React로 타이핑 효과 컴포넌트를 만드는 방법을 상세히 알아보겠습니다. 특히 HTML 태그와 복잡한 이모지를 완벽하게 지원하는 고급 기능까지 구현해보겠습니다.
주요 기능
우리가 만들 타이핑 컴포넌트의 주요 기능은 다음과 같습니다:
- ✨ ReactNode children 지원 (HTML 태그 포함)
- 🎯 복잡한 이모지 완벽 지원 (👨💻, 🧑🏻💻 등)
- ⚡ 즉시 표시 모드 지원
- ⏸️ 일시정지/재개 기능
- 🎨 커스터마이징 가능한 스타일
- 🔄 ref를 통한 제어 메서드 제공
컴포넌트 인터페이스 정의
먼저 컴포넌트의 Props와 Ref 인터페이스를 정의합니다:
export interface TypingProps {
children: ReactNode; // 타이핑할 콘텐츠
speed?: number; // 글자당 타이핑 속도 (ms)
showCursor?: boolean; // 커서 표시 여부
onComplete?: () => void; // 타이핑 완료 시 콜백
className?: string; // 컨테이너 클래스명
cursorClassName?: string; // 커서 추가 클래스명
immediate?: boolean; // 즉시 표시 모드
initialPaused?: boolean; // 일시정지 상태로 시작
}
export interface TypingRef {
reset: () => void; // 타이핑을 처음부터 다시 시작
pause: () => void; // 타이핑 일시정지
resume: () => void; // 타이핑 재개
complete: () => void; // 타이핑을 즉시 완료
}
핵심 구현
1. ReactNode를 HTML 문자열로 변환
가장 먼저 해결해야 할 문제는 ReactNode를 HTML 문자열로 변환하는 것입니다. 이를 통해 JSX로 작성된 복잡한 구조도 타이핑 효과로 표현할 수 있습니다:
const convertedText = useMemo(() => {
// ReactNode를 문자열로 변환하는 재귀 함수
const nodeToString = (node: ReactNode): string => {
// 문자열이나 숫자인 경우 그대로 반환
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
// 배열인 경우 각 요소를 변환하고 합침
if (Array.isArray(node)) {
return node.map(nodeToString).join('');
}
// React 엘리먼트인 경우
if (node && typeof node === 'object' && 'props' in node) {
const element = node as any;
const { children: elementChildren, ...props } = element.props || {};
// HTML 태그 생성
const tagName = element.type || 'span';
// 속성 처리 (className → class, style 객체 → 문자열 등)
const attributes = Object.entries(props)
.filter(([key]) => key !== 'children')
.map(([key, value]) => {
if (key === 'className') {
return `class="${value}"`;
}
if (key === 'style' && typeof value === 'object' && value !== null) {
// style 객체를 CSS 문자열로 변환
const styleStr = Object.entries(value)
.map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}:${v}`)
.join(';');
return `style="${styleStr}"`;
}
return `${key}="${value}"`;
})
.join(' ');
const openTag = `<${tagName}${attributes ? ' ' + attributes : ''}>`;
const closeTag = `</${tagName}>`;
const childContent = elementChildren ? nodeToString(elementChildren) : '';
return `${openTag}${childContent}${closeTag}`;
}
return '';
};
return nodeToString(children);
}, [children]);
2. 이모지 및 복합 문자 처리
복잡한 이모지(예: 👨💻, 🧑🏻💻)는 여러 개의 유니코드 문자가 결합된 형태입니다. 이를 올바르게 처리하려면 특별한 로직이 필요합니다:
const getNextCharacterIndex = useCallback((text: string, currentIndex: number): number => {
if (currentIndex >= text.length) {
return currentIndex;
}
// 현재 문자의 코드 포인트 가져오기
const codePoint = text.codePointAt(currentIndex);
if (!codePoint) {
return currentIndex + 1;
}
// 서로게이트 페어 처리 (4바이트 유니코드)
// 0xFFFF보다 큰 코드 포인트는 2개의 16비트 값으로 표현됨
if (codePoint > 0xffff) {
return currentIndex + 2;
}
// 이모지 범위 확인 함수
const isEmoji = (code: number): boolean => {
return (
(code >= 0x1f600 && code <= 0x1f64f) || // Emoticons
(code >= 0x1f300 && code <= 0x1f5ff) || // Misc Symbols and Pictographs
(code >= 0x1f680 && code <= 0x1f6ff) || // Transport and Map
(code >= 0x1f1e6 && code <= 0x1f1ff) || // Regional indicators
(code >= 0x2600 && code <= 0x26ff) || // Misc symbols
(code >= 0x2700 && code <= 0x27bf) || // Dingbats
(code >= 0xfe00 && code <= 0xfe0f) || // Variation Selectors
(code >= 0x1f900 && code <= 0x1f9ff) || // Supplemental Symbols and Pictographs
(code >= 0x1f018 && code <= 0x1f270) || // Various symbols
code === 0x200d || // Zero Width Joiner (ZWJ)
code === 0x20e3 // Combining Enclosing Keycap
);
};
// 이모지인 경우 복합 이모지 확인
if (isEmoji(codePoint)) {
let nextIndex = currentIndex + (codePoint > 0xffff ? 2 : 1);
// 복합 이모지 처리 (ZWJ 시퀀스, 스킨톤 수식어 등)
while (nextIndex < text.length) {
const nextCodePoint = text.codePointAt(nextIndex);
if (!nextCodePoint) {
break;
}
// Zero Width Joiner나 Variation Selector가 있으면 계속 진행
if (
nextCodePoint === 0x200d || // ZWJ (이모지 결합자)
(nextCodePoint >= 0xfe00 && nextCodePoint <= 0xfe0f) || // Variation Selectors
(nextCodePoint >= 0x1f3fb && nextCodePoint <= 0x1f3ff) || // Skin tone modifiers
nextCodePoint === 0x20e3 // Combining Enclosing Keycap
) {
nextIndex += nextCodePoint > 0xffff ? 2 : 1;
// ZWJ 다음에 오는 이모지도 포함
if (nextCodePoint === 0x200d && nextIndex < text.length) {
const followingCodePoint = text.codePointAt(nextIndex);
if (followingCodePoint && isEmoji(followingCodePoint)) {
nextIndex += followingCodePoint > 0xffff ? 2 : 1;
}
}
} else {
break;
}
}
return nextIndex;
}
// 일반 문자는 1글자씩
return currentIndex + 1;
}, []);
3. 문자 경계 사전 계산
성능 최적화를 위해 텍스트가 변경될 때 문자 경계를 미리 계산합니다. 이렇게 하면 타이핑 애니메이션 중에 복잡한 계산을 반복하지 않아도 됩니다:
useEffect(() => {
textContentRef.current = convertedText;
// immediate 모드일 경우 즉시 전체 텍스트 표시
if (immediate) {
setDisplayedContent(convertedText);
setIsComplete(true);
onComplete?.();
return;
}
// 문자 경계를 미리 계산
const boundaries: number[] = [0];
let i = 0;
while (i < convertedText.length) {
if (convertedText[i] === '<') {
// HTML 태그는 전체를 하나의 단위로 처리
const tagEnd = findTagEnd(convertedText, i);
i = tagEnd;
} else {
// 일반 문자나 이모지 처리
i = getNextCharacterIndex(convertedText, i);
}
boundaries.push(i);
}
characterBoundariesRef.current = boundaries;
reset();
}, [convertedText, reset, findTagEnd, getNextCharacterIndex, immediate, onComplete]);
4. 타이핑 애니메이션 구현
requestAnimationFrame을 사용하여 부드러운 타이핑 애니메이션을 구현합니다:
useEffect(() => {
if (
!textContentRef.current ||
isPaused ||
isComplete ||
characterBoundariesRef.current.length === 0 ||
immediate
) {
return;
}
let boundaryIndex = 0;
const typeWriter = (timestamp: number) => {
// 시간 추적을 위한 초기화
if (!lastTimeRef.current) {
lastTimeRef.current = timestamp;
}
const elapsed = timestamp - lastTimeRef.current;
totalTimeRef.current += elapsed;
lastTimeRef.current = timestamp;
const text = textContentRef.current;
const boundaries = characterBoundariesRef.current;
// speed 밀리초마다 한 문자씩 추가
while (totalTimeRef.current >= speed && boundaryIndex < boundaries.length - 1) {
boundaryIndex++;
// HTML 태그가 아닌 경우에만 시간 소모
// (태그는 즉시 렌더링되어야 하므로)
const currentPos = boundaries[boundaryIndex - 1];
if (currentPos < text.length && text[currentPos] !== '<') {
totalTimeRef.current -= speed;
}
}
// 현재 경계까지의 내용 렌더링
const currentBoundary = boundaries[Math.min(boundaryIndex, boundaries.length - 1)];
const currentContent = text.substring(0, currentBoundary);
setDisplayedContent(currentContent);
// 타이핑이 완료되었는지 확인
if (boundaryIndex >= boundaries.length - 1) {
setIsComplete(true);
animationIdRef.current = null;
onComplete?.();
} else if (!isPaused) {
// 다음 프레임 요청
animationIdRef.current = requestAnimationFrame(typeWriter);
}
};
// 애니메이션 시작
animationIdRef.current = requestAnimationFrame(typeWriter);
// 클린업 함수
return () => {
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
};
}, [speed, onComplete, isPaused, isComplete, immediate]);
5. 커서 애니메이션
CSS 모듈을 사용하여 깜빡이는 커서를 구현합니다:
/* typing.module.css */
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.cursor {
animation: blink 0.7s infinite;
}
사용 예제
기본 사용법
<Typing speed={100}>
안녕하세요! 타이핑 효과 예제입니다.
</Typing>
HTML 태그와 스타일 포함
<Typing speed={80} className="text-lg font-bold">
오늘의 <span className="text-red-500">중요한</span> 일정을
<br />
확인해보세요!
</Typing>
Ref를 통한 제어
const typingRef = useRef<TypingRef>(null);
return (
<div>
<Typing ref={typingRef} speed={100}>
제어 가능한 타이핑 텍스트입니다.
</Typing>
<div className="mt-4 space-x-2">
<button onClick={() => typingRef.current?.pause()}>일시정지</button>
<button onClick={() => typingRef.current?.resume()}>재개</button>
<button onClick={() => typingRef.current?.reset()}>리셋</button>
<button onClick={() => typingRef.current?.complete()}>완료</button>
</div>
</div>
);
순차적 타이핑
const [step, setStep] = useState(0);
return (
<div className="space-y-4">
<Typing
speed={100}
onComplete={() => setStep(1)}
>
첫 번째 메시지입니다.
</Typing>
{step >= 1 && (
<Typing
speed={100}
onComplete={() => setStep(2)}
>
두 번째 메시지가 나타납니다.
</Typing>
)}
{step >= 2 && (
<Typing speed={100}>
마지막 메시지입니다! 🎉
</Typing>
)}
</div>
);
성능 최적화
이 컴포넌트는 다음과 같은 성능 최적화 기법을 사용합니다:
- useMemo: ReactNode to HTML 변환 결과를 메모이제이션
- useCallback: 자주 호출되는 함수들을 메모이제이션
- 사전 계산: 문자 경계를 미리 계산하여 애니메이션 중 연산 최소화
- requestAnimationFrame: 브라우저의 리페인트 주기에 맞춘 부드러운 애니메이션
마무리
이렇게 구현한 타이핑 컴포넌트는 단순한 텍스트뿐만 아니라 복잡한 HTML 구조와 이모지도 완벽하게 처리할 수 있습니다. ref를 통한 제어 기능으로 다양한 인터랙션도 구현할 수 있어 실제 프로젝트에서 유용하게 사용할 수 있을 것입니다.
React 공식 문서의 권장사항에 따라 React 19의 새로운 기능들을 활용하여 구현했으며, TypeScript로 타입 안전성도 확보했습니다. 이 컴포넌트를 기반으로 여러분만의 창의적인 타이핑 효과를 만들어보세요!