툴팁·팝오버·컨텍스트 도움말: 정보를 방해 없이 전달하는 UI 설계

UX 디자인

툴팁팝오버컨텍스트 도움말인포 UI도움말 설계

이 글은 누구를 위한 것인가

  • 툴팁과 팝오버의 차이를 모르고 혼용하는 팀
  • 모바일에서 툴팁이 안 보이는 문제를 해결하고 싶은 개발자
  • 복잡한 기능을 사용자에게 설명하는 도움말 UI를 설계하는 팀

들어가며

"이 버튼이 뭘 하는지" 궁금한 사용자에게 매뉴얼 링크를 주는 것은 실패다. 컨텍스트 바로 옆에서 간단하게 설명해주는 것이 핵심이다. 단, 모바일에서 호버가 없으므로 툴팁만으로는 부족하다.

이 글은 bluefoxdev.kr의 컨텍스트 도움말 UX 를 참고하여 작성했습니다.


1. 도움말 UI 유형 선택

[도움말 컴포넌트 선택 가이드]

툴팁 (Tooltip):
  트리거: 호버 (마우스 올릴 때)
  내용: 짧은 레이블 (1-2줄)
  사라짐: 마우스 이동 시 자동
  용도: 아이콘 버튼 레이블, 축약 텍스트 설명
  ❌ 중요한 정보는 넣지 말 것 (발견 어려움)
  ❌ 모바일에서 작동 안 함

팝오버 (Popover):
  트리거: 클릭
  내용: 긴 설명, 링크, 이미지 포함 가능
  사라짐: 외부 클릭 또는 닫기 버튼
  용도: 설정 설명, 용어 설명, 미리보기
  ✅ 모바일에서도 작동

인포 아이콘 + 팝오버:
  패턴: ℹ 아이콘 → 클릭 → 팝오버
  용도: 폼 필드 설명, 복잡한 개념 설명
  최적: 한 화면에 3개 이하

코치마크 (Coachmark):
  트리거: 첫 방문 또는 새 기능 출시
  내용: 기능 하이라이트 + 설명
  사라짐: 확인 클릭
  용도: 온보딩, 신규 기능 안내

인라인 도움말:
  항상 보임 (접기/펼치기 없음)
  용도: 복잡한 입력 형식, 오류 설명
  ✅ 가장 접근하기 쉬움

[모바일 툴팁 대안]
  정보 아이콘 (ℹ) + 탭 → 바텀시트로 설명
  인라인 도움말 텍스트 (항상 표시)
  Expandable 설명 (펼치기/닫기)

2. 위치 자동 계산 툴팁

import { useState, useRef, useEffect } from "react";

type TooltipPosition = "top" | "bottom" | "left" | "right";

interface TooltipProps {
  content: string;
  children: React.ReactNode;
  position?: TooltipPosition;
  delay?: number;
}

function Tooltip({ content, children, position = "top", delay = 300 }: TooltipProps) {
  const [isVisible, setIsVisible] = useState(false);
  const [actualPosition, setActualPosition] = useState<TooltipPosition>(position);
  const triggerRef = useRef<HTMLDivElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const timerRef = useRef<NodeJS.Timeout>();

  const calculatePosition = () => {
    if (!triggerRef.current || !tooltipRef.current) return;

    const triggerRect = triggerRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();
    const viewport = { width: window.innerWidth, height: window.innerHeight };

    let pos = position;

    // 화면 밖으로 나가는지 확인하고 반전
    if (pos === "top" && triggerRect.top < tooltipRect.height + 8) pos = "bottom";
    if (pos === "bottom" && triggerRect.bottom + tooltipRect.height + 8 > viewport.height) pos = "top";
    if (pos === "left" && triggerRect.left < tooltipRect.width + 8) pos = "right";
    if (pos === "right" && triggerRect.right + tooltipRect.width + 8 > viewport.width) pos = "left";

    setActualPosition(pos);
  };

  const positionStyles: Record<TooltipPosition, string> = {
    top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
    bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
    left: "right-full top-1/2 -translate-y-1/2 mr-2",
    right: "left-full top-1/2 -translate-y-1/2 ml-2",
  };

  const arrowStyles: Record<TooltipPosition, string> = {
    top: "top-full left-1/2 -translate-x-1/2 border-t-gray-800",
    bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-gray-800",
    left: "left-full top-1/2 -translate-y-1/2 border-l-gray-800",
    right: "right-full top-1/2 -translate-y-1/2 border-r-gray-800",
  };

  return (
    <div
      ref={triggerRef}
      className="relative inline-flex"
      onMouseEnter={() => {
        timerRef.current = setTimeout(() => {
          setIsVisible(true);
          requestAnimationFrame(calculatePosition);
        }, delay);
      }}
      onMouseLeave={() => {
        clearTimeout(timerRef.current);
        setIsVisible(false);
      }}
      // 접근성: 포커스에도 표시
      onFocus={() => setIsVisible(true)}
      onBlur={() => setIsVisible(false)}
    >
      {children}

      {isVisible && (
        <div
          ref={tooltipRef}
          role="tooltip"
          className={`absolute z-50 px-3 py-1.5 text-xs text-white bg-gray-800 rounded-lg whitespace-nowrap pointer-events-none ${positionStyles[actualPosition]}`}
        >
          {content}
          {/* 화살표 */}
          <div
            className={`absolute w-0 h-0 border-4 border-transparent ${arrowStyles[actualPosition]}`}
          />
        </div>
      )}
    </div>
  );
}

// 사용 예시
function IconButton({ icon, label, onClick }: IconButtonProps) {
  return (
    <Tooltip content={label} position="bottom">
      <button
        onClick={onClick}
        aria-label={label}
        className="p-2 rounded-lg hover:bg-gray-100"
      >
        {icon}
      </button>
    </Tooltip>
  );
}

마무리

툴팁은 "모바일에서 작동하지 않는다"는 것을 항상 기억해야 한다. 모바일 사용자는 호버를 할 수 없다. 중요한 설명은 인라인 텍스트나 팝오버(클릭)로 처리하고, 툴팁은 데스크탑에서 아이콘 버튼 레이블 정도에만 쓰는 것이 안전하다.