Tooltip
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
Tooltip
hover/focus 시 짧은 설명을 띄우는 컴포넌트입니다.
단일 자식 요소를 감싸 cloneElement로 이벤트를 위임하므로 추가 wrapper 없이 동작합니다.
Placement
4방향 배치 — 화살표가 자동으로 따라갑니다.
Top
Live Preview
Right
Live Preview
Bottom
Live Preview
Left
Live Preview
Delay
hover에서 노출까지의 지연 시간(ms)을 지정합니다.
No delay
Live Preview
500ms
Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
Tooltiptypescript
'use client';
/**
* Tooltip — hover/focus 시 짧은 설명을 띄우는 컴포넌트.
*
* - 트리거 요소를 children으로 감싸 사용한다.
* - placement 4방향 (top / bottom / left / right) 지원, 화살표 자동 위치.
* - delay(ms)로 노출 지연을 줄 수 있어 성급한 hover 출현을 막을 수 있다.
* - 단순 absolute 포지셔닝 — 뷰포트 가장자리 collision 회피는 미지원 (필요 시 Floating UI 도입).
*/
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useCallback,
useEffect,
useRef,
useState,
type HTMLAttributes,
type ReactElement,
type ReactNode,
} from 'react';
import { cn } from '@/lib/cn';
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipProps extends Omit<HTMLAttributes<HTMLDivElement>, 'content'> {
/** 툴팁 내용. 짧은 텍스트 권장. */
content: ReactNode;
/** 트리거 — 단일 요소가 권장됨 (이벤트 바인딩 cloneElement 동작). */
children: ReactNode;
/** 노출 위치 (default: top). */
placement?: TooltipPlacement;
/** 노출 전 지연(ms, default: 200). */
delay?: number;
/** 비활성화 — true면 툴팁이 떠오르지 않음. */
disabled?: boolean;
/** 툴팁 패널 추가 클래스. */
panelClassName?: string;
}
const placementStyles: Record<TooltipPlacement, { panel: string; arrow: string }> = {
top: {
panel: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
arrow:
'top-full left-1/2 -translate-x-1/2 -mt-px border-l-transparent border-r-transparent border-b-transparent border-t-slate-900',
},
bottom: {
panel: 'top-full left-1/2 -translate-x-1/2 mt-2',
arrow:
'bottom-full left-1/2 -translate-x-1/2 -mb-px border-l-transparent border-r-transparent border-t-transparent border-b-slate-900',
},
left: {
panel: 'right-full top-1/2 -translate-y-1/2 mr-2',
arrow:
'left-full top-1/2 -translate-y-1/2 -ml-px border-t-transparent border-b-transparent border-r-transparent border-l-slate-900',
},
right: {
panel: 'left-full top-1/2 -translate-y-1/2 ml-2',
arrow:
'right-full top-1/2 -translate-y-1/2 -mr-px border-t-transparent border-b-transparent border-l-transparent border-r-slate-900',
},
};
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
(
{
content,
children,
placement = 'top',
delay = 200,
disabled = false,
panelClassName,
className,
...rest
},
ref,
) => {
const [open, setOpen] = useState(false);
const timerRef = useRef<number | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current != null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const show = useCallback(() => {
if (disabled) return;
clearTimer();
timerRef.current = window.setTimeout(() => setOpen(true), delay);
}, [disabled, delay, clearTimer]);
const hide = useCallback(() => {
clearTimer();
setOpen(false);
}, [clearTimer]);
useEffect(() => clearTimer, [clearTimer]);
/** 트리거 element가 단일 React element면 그 위에 직접 이벤트를 바인딩 — 추가 wrapper div 불필요 */
const onlyChild = Children.count(children) === 1 ? Children.only(children) : null;
const useWrapper = !isValidElement(onlyChild);
const handlers = {
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
};
const trigger = !useWrapper && isValidElement(onlyChild)
? cloneElement(
onlyChild as ReactElement<{
onMouseEnter?: (e: React.MouseEvent) => void;
onMouseLeave?: (e: React.MouseEvent) => void;
onFocus?: (e: React.FocusEvent) => void;
onBlur?: (e: React.FocusEvent) => void;
}>,
{
onMouseEnter: (e: React.MouseEvent) => {
const child = onlyChild as ReactElement<{
onMouseEnter?: (e: React.MouseEvent) => void;
}>;
child.props.onMouseEnter?.(e);
show();
},
onMouseLeave: (e: React.MouseEvent) => {
const child = onlyChild as ReactElement<{
onMouseLeave?: (e: React.MouseEvent) => void;
}>;
child.props.onMouseLeave?.(e);
hide();
},
onFocus: (e: React.FocusEvent) => {
const child = onlyChild as ReactElement<{
onFocus?: (e: React.FocusEvent) => void;
}>;
child.props.onFocus?.(e);
show();
},
onBlur: (e: React.FocusEvent) => {
const child = onlyChild as ReactElement<{
onBlur?: (e: React.FocusEvent) => void;
}>;
child.props.onBlur?.(e);
hide();
},
},
)
: null;
return (
<div
ref={ref}
className={cn('relative inline-flex', className)}
{...(useWrapper ? handlers : {})}
{...rest}
>
{trigger ?? children}
{open && !disabled && (
<div
role="tooltip"
className={cn(
'pointer-events-none absolute z-50 whitespace-nowrap rounded-lg bg-slate-900 px-2.5 py-1.5 text-xs font-semibold text-white shadow-lg shadow-slate-900/20 transition-opacity duration-150',
'animate-in fade-in zoom-in-95',
placementStyles[placement].panel,
panelClassName,
)}
>
{content}
<span
aria-hidden
className={cn(
'absolute h-0 w-0 border-[5px]',
placementStyles[placement].arrow,
)}
/>
</div>
)}
</div>
);
},
);
Tooltip.displayName = 'Tooltip';