Tooltip
悬停 400ms 或键盘聚焦时出现的小提示,popover 底色、12px 字号,可带快捷键。
'use client';
import { Bell, Gear, PencilSimple, ShareNetwork } from '@phosphor-icons/react';
import { Button } from '@/components/ui/button';
import { Tooltip } from '@/components/ui/tooltip';
export default function TooltipDemo() {
return (
<div className="flex items-center gap-1 rounded-xl bg-surface-2 p-1 shadow-panel">
<Tooltip content="编辑" shortcut="E" side="top">
<Button variant="ghost" size="icon" aria-label="编辑"><PencilSimple /></Button>
</Tooltip>
<Tooltip content="分享" side="bottom">
<Button variant="ghost" size="icon" aria-label="分享"><ShareNetwork /></Button>
</Tooltip>
<Tooltip content="通知" side="left">
<Button variant="ghost" size="icon" aria-label="通知"><Bell /></Button>
</Tooltip>
<Tooltip content="设置" shortcut="⌘," side="right">
<Button variant="ghost" size="icon" aria-label="设置"><Gear /></Button>
</Tooltip>
</div>
);
}
安装
'use client';
import { AnimatePresence, motion } from 'motion/react';
import {
cloneElement,
isValidElement,
useEffect,
useId,
useRef,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
import { Portal } from './overlay';
export type TooltipSide = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipProps {
/** 提示内容;为空时只渲染 children */
content: ReactNode;
/** 单个可聚焦元素(按钮 / 链接);会注入 aria-describedby */
children: ReactNode;
side?: TooltipSide;
/** 悬停延迟,默认 400ms(键盘聚焦立即显示) */
delay?: number;
/** 右侧快捷键提示,如 "⌘K" */
shortcut?: ReactNode;
disabled?: boolean;
className?: string;
}
const GAP = 6;
function place(r: DOMRect, side: TooltipSide) {
switch (side) {
case 'top':
return { left: r.left + r.width / 2, top: r.top - GAP, transform: 'translate(-50%, -100%)' };
case 'bottom':
return { left: r.left + r.width / 2, top: r.bottom + GAP, transform: 'translate(-50%, 0)' };
case 'left':
return { left: r.left - GAP, top: r.top + r.height / 2, transform: 'translate(-100%, -50%)' };
case 'right':
return { left: r.right + GAP, top: r.top + r.height / 2, transform: 'translate(0, -50%)' };
}
}
const enterOffset: Record<TooltipSide, { x?: number; y?: number }> = {
top: { y: 3 },
bottom: { y: -3 },
left: { x: 3 },
right: { x: -3 },
};
/** 悬停 / 聚焦提示:400ms 延迟,popover 底,12px 小字 */
export function Tooltip({ content, children, side = 'top', delay = 400, shortcut, disabled, className }: TooltipProps) {
const id = useId();
const anchor = useRef<HTMLSpanElement>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<ReturnType<typeof place> | null>(null);
const reduce = useReducedMotionPref();
const show = (immediate?: boolean) => {
if (disabled || content == null || content === '') return;
clearTimeout(timer.current);
const go = () => {
const el = anchor.current?.firstElementChild ?? anchor.current;
if (!el) return;
setPos(place(el.getBoundingClientRect(), side));
setOpen(true);
};
if (immediate) go();
else timer.current = setTimeout(go, delay);
};
const hide = () => {
clearTimeout(timer.current);
setOpen(false);
};
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
document.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('scroll', hide, true);
window.removeEventListener('resize', hide);
document.removeEventListener('keydown', onKey);
};
}, [open]);
useEffect(() => () => clearTimeout(timer.current), []);
const child = isValidElement(children)
? cloneElement(children as ReactElement<{ 'aria-describedby'?: string }>, { 'aria-describedby': open ? id : undefined })
: children;
return (
<>
<span
ref={anchor}
className="inline-flex"
onPointerEnter={(e) => e.pointerType === 'mouse' && show()}
onPointerLeave={hide}
onPointerDown={hide}
onFocus={(e) => {
if ((e.target as HTMLElement).matches?.(':focus-visible')) show(true);
}}
onBlur={hide}
>
{child}
</span>
<Portal>
<AnimatePresence>
{open && pos && (
<div className="pointer-events-none fixed z-[70]" style={pos}>
<motion.div
id={id}
role="tooltip"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, ...enterOffset[side] }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.08 } }}
transition={reduce ? { duration: 0 } : SPRING_SNAPPY}
className={twMerge(
'flex max-w-[240px] items-center gap-2 rounded-md bg-popover-surface px-2 py-1 text-[12px] leading-snug text-text-primary shadow-popover',
className,
)}
>
<span>{content}</span>
{shortcut && <span className="font-medium text-text-muted tabular-nums">{shortcut}</span>}
</motion.div>
</div>
)}
</AnimatePresence>
</Portal>
</>
);
}
用法
import { Tooltip } from "@/components/ui/tooltip";
<Tooltip content="设置" shortcut="⌘," side="right">
<Button variant="ghost" size="icon" aria-label="设置"><Gear /></Button>
</Tooltip>| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
content | ReactNode | — | 提示内容 |
side | top bottom left right | top | 方向 |
delay | number | 400 | 悬停延迟(ms) |
shortcut | ReactNode | — | 右侧快捷键 |