tooltip · neumorphic · 灵感来源:Neumorphism(同色浮起的小块)
悬停 350ms 才出现,键盘聚焦立即出现;Esc 可关
浮层不会挡住触发器:鼠标可以直接移上去
Tooltip (WCAG 1.4.13) · 拟态提示 · 提示 / Neumorphic
做一个 Tooltip (WCAG 1.4.13)(拟态提示):
- 同一份 props(content / children / placement)
- 同色底 + 外投影,没有边框;投影够散但不能糊住 2xs 文字
- 停留、Esc 关闭与 aria 开关规则同玻璃提示
用这些 CSS 变量:--color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-border --neuro-bg --neuro-dark --neuro-light --text-2xs
灵感参考:Neumorphism(同色浮起的小块)--color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-border --neuro-bg --neuro-dark --neuro-light --text-2xs
焦点
(尚未接进整页场景)
/r/tooltip--neumorphic.json
免外壳的 markdown 版:/llms/components/tooltip--neumorphic.mdaria-describedby 开关规则与玻璃提示一致import {
cloneElement,
useEffect,
useId,
useRef,
useState,
type FocusEventHandler,
type PointerEventHandler,
type ReactElement,
type ReactNode,
} from 'react';
import { useOverlay } from '~/lib/overlay';
import { motionMs } from '~/lib/motion';
import { Portal, layerStyle, useLayerPosition } from '~/lib/layer';
interface TriggerProps {
'aria-describedby'?: string;
onPointerEnter?: PointerEventHandler<HTMLElement>;
onPointerLeave?: PointerEventHandler<HTMLElement>;
onFocus?: FocusEventHandler<HTMLElement>;
onBlur?: FocusEventHandler<HTMLElement>;
}
interface Props {
content: ReactNode;
/** 必须是可聚焦的元素:按钮、链接或 tabIndex=0 的节点 */
children: ReactElement<TriggerProps>;
placement?: 'top' | 'bottom';
className?: string;
}
export function NeumorphicTooltip({ content, children, placement = 'top', className = '' }: Props) {
const id = useId();
const [open, setOpen] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { mounted, entering, leaving } = useOverlay(open);
const { triggerRef, layerRef, pos } = useLayerPosition(mounted, { placement });
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
const showNow = () => {
clear();
setOpen(true);
};
const close = () => {
clear();
setOpen(false);
};
const showAfterDwell = () => {
clear();
timer.current = setTimeout(() => setOpen(true), motionMs('--dwell-tooltip', 350));
};
useEffect(() => clear, []);
// 悬停内容要能用 Esc 关掉(SC 1.4.13),焦点不进入浮层
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
const trigger = cloneElement(children, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node;
const own = (children as { ref?: unknown }).ref;
if (typeof own === 'function') own(node);
},
'aria-describedby': open ? id : undefined,
onPointerEnter: (e) => {
showAfterDwell();
children.props.onPointerEnter?.(e);
},
onPointerLeave: (e) => {
close();
children.props.onPointerLeave?.(e);
},
onFocus: (e) => {
showNow();
children.props.onFocus?.(e);
},
onBlur: (e) => {
close();
children.props.onBlur?.(e);
},
} as TriggerProps & { ref: (node: HTMLElement | null) => void });
return (
<>
{trigger}
{mounted && (
<Portal>
<div
ref={layerRef}
role="tooltip"
id={id}
style={layerStyle(pos)}
className={`
z-50 pointer-events-none
max-w-[240px] px-2.5 py-1.5 rounded-lg
text-[length:var(--text-2xs)] leading-snug
bg-[var(--neuro-bg)] border border-transparent
shadow-[4px_4px_12px_var(--neuro-dark),-4px_-4px_12px_var(--neuro-light)] text-[var(--color-text)]
transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]
${entering || leaving ? 'opacity-0 translate-y-0.5' : 'opacity-100 translate-y-0'}
${className}
`}
>
{content}
</div>
</Portal>
)}
</>
);
}
src/components/tooltip/neumorphic/