Tooltip Portal
Tooltip Portal
Tooltips must appear next to the trigger element but above all other content. They break out
of any parent with overflow: hidden using a Portal mounted to document.body.
Tooltip.jsx
import { createPortal } from 'react-dom';
import { useState, useRef, useEffect } from 'react';
function Tooltip({ text, children }) {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
function show() {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.top + window.scrollY - 40, // 40px above the element
left: rect.left + window.scrollX + rect.width / 2
});
setVisible(true);
}
function hide() {
setVisible(false);
}
const tooltip = visible
? createPortal(
<div
role="tooltip"
style={{
position: 'absolute',
top: position.top,
left: position.left,
transform: 'translateX(-50%)',
background: '#333',
color: '#fff',
padding: '4px 10px',
borderRadius: '4px',
fontSize: '0.85rem',
pointerEvents: 'none',
zIndex: 99999,
whiteSpace: 'nowrap'
}}
>
{text}
</div>,
document.body
)
: null;
return (
<>
<span
ref={triggerRef}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
tabIndex={0}
aria-describedby="tooltip"
>
{children}
</span>
{tooltip}
</>
);
}
export default Tooltip;
Usage
<Tooltip text="This is a helpful tooltip">
<button>Hover over me</button>
</Tooltip>
Why getBoundingClientRect() + scrollY?
getBoundingClientRect() returns position relative to the viewport.
Adding window.scrollY and window.scrollX converts it to
document-absolute coordinates, which is needed because the tooltip uses
position: absolute relative to the document root.
