Tooltip

Interactive UI Explorer

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

Live Preview

Tooltip

hover/focus 시 짧은 설명을 띄우는 컴포넌트입니다.
단일 자식 요소를 감싸 cloneElement로 이벤트를 위임하므로 추가 wrapper 없이 동작합니다.

Placement

4방향 배치 — 화살표가 자동으로 따라갑니다.

Top
<Tooltip content="위쪽 툴팁" placement="top"><Button>Top</Button></Tooltip>
Live Preview
Right
<Tooltip content="오른쪽 툴팁" placement="right"><Button>Right</Button></Tooltip>
Live Preview
Bottom
<Tooltip content="아래쪽 툴팁" placement="bottom"><Button>Bottom</Button></Tooltip>
Live Preview
Left
<Tooltip content="왼쪽 툴팁" placement="left"><Button>Left</Button></Tooltip>
Live Preview

Delay

hover에서 노출까지의 지연 시간(ms)을 지정합니다.

No delay
<Tooltip content="즉시 노출" delay={0}>...</Tooltip>
Live Preview
500ms
<Tooltip content="0.5초 후 노출" delay={500}>...</Tooltip>
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';