컴포넌트

Tooltip

요소에 대한 짧은 설명을 표시합니다.

모션

100ms 동안 작은 확대와 투명도 변화를 적용합니다. 움직임 감소 설정에서는 전환을 생략합니다. 공통 모션 규칙을 따릅니다.

설치

pnpm dlx ply-ui add tooltip

공통 설치 설정을 먼저 적용합니다.

사용법

Provider.delay로 표시 지연을 정하고 Positioner로 위치를 지정합니다.

필수 안내나 클릭해야 하는 콘텐츠는 Tooltip 대신 본문이나 Popover에 넣으세요.

구성

구성 요소역할
Provider여러 툴팁의 표시 지연을 설정합니다.
Root툴팁 하나의 상태를 관리합니다.
Trigger마우스 또는 키보드 포커스로 설명을 표시하는 대상입니다.
Portal / Positioner / Popup설명의 위치와 표면을 구성합니다.
Arrow대상 방향을 가리키는 표시입니다.

설명 연결

현재 도움말을 연 Trigger만 Popup을 aria-describedby로 연결합니다. 기존 설명 ID는 유지하며, 여러 Trigger가 하나의 Root를 공유하거나 handle로 외부 Trigger를 연결한 경우에도 자동으로 처리합니다.

구현 코드

components/ui/tooltip.tsx
tooltip.tsx
"use client";

import {
  cloneElement,
  useSyncExternalStore,
  createContext,
  useContext,
  useId,
  useLayoutEffect,
  useState,
  type ComponentProps,
} from "react";
import { useRender } from "@base-ui/react/use-render";
import {
  createTooltipDescription,
  descriptionForHandle,
  type TooltipDescription,
} from "../../lib/tooltip-description";
import { Tooltip as Primitive } from "@base-ui/react/tooltip";
import { withClassName } from "../../lib/cx";
import { createThemedPortal } from "../../lib/create-themed-portal";
import "./tooltip.css";

const ThemedPortal = createThemedPortal(Primitive.Portal);

const DescriptionContext = createContext<TooltipDescription | null>(null);
const emptyDescription = createTooltipDescription();

/** 표시 상태는 Base UI 한 곳에서 관리하고 설명 ID만 공유합니다. */
function TooltipRoot<Payload>(props: Primitive.Root.Props<Payload>) {
  const [local] = useState(createTooltipDescription);
  const description = props.handle ? descriptionForHandle(props.handle) : local;
  return (
    <DescriptionContext.Provider value={description}>
      <Primitive.Root {...props} />
    </DescriptionContext.Provider>
  );
}
function TooltipTrigger<Payload>({
  className,
  render,
  ...props
}: Primitive.Trigger.Props<Payload>) {
  const local = useContext(DescriptionContext);
  const description = props.handle
    ? descriptionForHandle(props.handle)
    : (local ?? emptyDescription);
  const id = useSyncExternalStore(
    description.subscribe,
    description.getSnapshot,
    description.getServerSnapshot,
  );
  return (
    <Primitive.Trigger
      {...props}
      className={withClassName("rbx-button", className)}
      render={(elementProps, state) => (
        <TooltipTriggerElement
          elementProps={elementProps}
          state={state}
          render={render}
          descriptionId={id}
        />
      )}
    />
  );
}
function TooltipTriggerElement({
  elementProps,
  state,
  render,
  descriptionId,
}: {
  elementProps: ComponentProps<"button">;
  state: Primitive.Trigger.State;
  render: Primitive.Trigger.Props<unknown>["render"];
  descriptionId?: string;
}) {
  const { ref, ...props } = elementProps;
  const element = useRender({
    defaultTagName: "button",
    render,
    ref,
    props,
    state: { ...state },
  });
  const describedBy = (element.props as ComponentProps<"button">)[
    "aria-describedby"
  ];
  const ids =
    [
      ...new Set(
        [
          ...(describedBy?.split(/\s+/) ?? []),
          state.open ? descriptionId : undefined,
        ].filter(Boolean),
      ),
    ].join(" ") || undefined;
  return cloneElement(element, {
    "aria-describedby": ids,
  } as ComponentProps<"button">);
}
function TooltipPopup({
  className,
  id: suppliedId,
  render,
  children,
  ...props
}: ComponentProps<typeof Primitive.Popup>) {
  const generatedId = useId();
  const id = suppliedId ?? generatedId;
  const description = useContext(DescriptionContext);
  return (
    <Primitive.Popup
      role="tooltip"
      {...props}
      id={id}
      className={withClassName("rbx-tooltip", className)}
      render={(elementProps, state) => (
        <TooltipPopupSurface
          elementProps={elementProps}
          state={state}
          render={render}
          description={description}
        />
      )}
    >
      {children}
    </Primitive.Popup>
  );
}
function TooltipPopupSurface({
  elementProps,
  state,
  render,
  description,
}: {
  elementProps: ComponentProps<"div">;
  state: Primitive.Popup.State;
  render: ComponentProps<typeof Primitive.Popup>["render"];
  description: TooltipDescription | null;
}) {
  const { ref, ...props } = elementProps;
  const element = useRender({
    defaultTagName: "div",
    render,
    ref,
    props,
    state: { ...state },
  });
  const { id, children } = element.props as ComponentProps<"div">;
  // render 요소가 지정한 실제 ID와 내용도 설명 연결에 사용합니다.
  useLayoutEffect(() => {
    description?.set(id);
    return () => {
      if (description?.getSnapshot() === id) description?.set(undefined);
    };
  }, [description, id]);
  return cloneElement(
    element,
    {},
    <div className="rbx-tooltip-content">{children}</div>,
  );
}

function TooltipPositioner({
  className,
  ...props
}: ComponentProps<typeof Primitive.Positioner>) {
  return (
    <Primitive.Positioner
      {...props}
      className={withClassName("rbx-positioner", className)}
    />
  );
}

function TooltipArrow({
  className,
  ...props
}: ComponentProps<typeof Primitive.Arrow>) {
  return (
    <Primitive.Arrow
      {...props}
      className={withClassName("rbx-tooltip-arrow", className)}
    />
  );
}

// Provider, handle 등 나머지 API는 Base UI 원본을 그대로 노출합니다.
export const Tooltip = {
  ...Primitive,
  Root: TooltipRoot,
  Portal: ThemedPortal,
  Trigger: TooltipTrigger,
  Popup: TooltipPopup,
  Positioner: TooltipPositioner,
  Arrow: TooltipArrow,
};
components/ui/tooltip.css
tooltip.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;
@import "./button.css";
@layer components.ply {
  /* Tooltip: 공통 구조는 theme.css, 컴포넌트 전용 조정은 아래에 작성합니다. */
  .rbx-tooltip {
    padding: 8px 12px;
    border-radius: 8px;
    background: var(--rbx-color-content-emphasis);
    color: var(--rbx-color-surface-0);
    font-size: 0.875rem;
    box-shadow: 0 4px 16px var(--rbx-color-common-shadow);
    transform-origin: var(--transform-origin);
    transition:
      opacity var(--rbx-motion-duration-feedback) var(--rbx-motion-ease-enter),
      scale var(--rbx-motion-duration-feedback) var(--rbx-motion-ease-enter);
    max-width: min(280px, var(--available-width, 100vw), calc(100vw - 16px));
    overflow-wrap: anywhere;
  }
  .rbx-tooltip:is([data-starting-style], [data-ending-style]) {
    opacity: 0;
    scale: var(--rbx-motion-scale-overlay);
  }
  .rbx-tooltip[data-ending-style] {
    transition-timing-function: var(--rbx-motion-ease-exit);
  }
  .rbx-tooltip-arrow {
    fill: var(--rbx-color-content-emphasis);
  }

  .rbx-tooltip-content {
    max-height: max(32px, calc(var(--available-height, 100dvh) - 32px));
    overflow: auto;
  }
}

목차