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

UX 디자인

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

이 글은 누구를 위한 것인가

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

들어가며

"이 버튼이 뭘 하는지" 궁금한 사용자에게 매뉴얼 링크를 주는 것은 실패다. 컨텍스트 바로 옆에서 간단하게 설명해주는 것이 핵심이다. 단, 모바일에서 호버가 없으므로 툴팁만으로는 부족하다. 키보드 접근과 해제 동작은 ARIA tooltip 패턴에, 네이티브 구현은 Popover API에 정리돼 있다.

이 글은 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>
  );
}

마무리

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