컴포넌트
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
"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
@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;
}
}