Tooltips look simple.
They are not.
If you’ve ever tried building one properly, you’ll realize quickly that it’s not about styling — it’s about behavior.
In this article, I’ll walk through a simple accessible Tooltip implementation in React and focus only on three important aspects:
- ⏱ Time interval handling (prevent flicker)
- ⌨ Escape key handling (keyboard accessibility)
- 📍 CSS positioning (clean layout without layout hacks)
The Goal
We want a tooltip that:
- Appears on hover and focus
- Disappears on mouse leave and blur
- Closes when pressing Escape
- Doesn’t flicker on quick mouse movement
- Is positioned cleanly using CSS
Implementation
import {
useEffect,
useRef,
useState,
cloneElement,
isValidElement,
type ReactElement,
useId,
} from "react";
import styles from "./style.module.css";
interface TooltipProps {
children: ReactElement;
title: string;
}
const DELAY_INTERVAL = 200;
function Tooltip({ children, title }: TooltipProps) {
const [showTitle, setShowTitle] = useState(false);
const openTimer = useRef(-1);
const closeTimer = useRef(-1);
const tooltipId = useId();
function show() {
clearTimeout(openTimer.current);
openTimer.current = setTimeout(() => setShowTitle(true), DELAY_INTERVAL);
}
function hide() {
clearTimeout(closeTimer.current);
clearTimeout(openTimer.current);
closeTimer.current = setTimeout(() => setShowTitle(false), DELAY_INTERVAL);
}
useEffect(() => {
function keydownHandler(e: KeyboardEvent) {
const { key } = e;
if (key === "Escape") {
setShowTitle(false);
}
}
if (showTitle) {
document.addEventListener("keydown", keydownHandler);
}
return () => {
if (showTitle) {
document.removeEventListener("keydown", keydownHandler);
}
};
}, [showTitle]);
if (!isValidElement(children)) {
throw new Error("Tooltip expects a single React element child");
}
const trigger = cloneElement(children, {
onMouseEnter: (e) => {
children.props.onMouseEnter?.(e);
show();
},
onMouseLeave: (e) => {
children.props.onMouseLeave?.(e);
hide();
},
onFocus: (e) => {
children.props.onFocus?.(e);
show();
},
onBlur: (e) => {
children.props.onBlur?.(e);
hide();
},
"aria-describedby": showTitle ? tooltipId : undefined,
});
return (
<div className={styles.container}>
{trigger}
{showTitle ? (
<div className={styles.titleContainer}>
<div role="tooltip" id={tooltipId} className={styles.titleWrapper}>
{title}
</div>
</div>
) : null}
</div>
);
}
export default Tooltip;
CSS
.container {
position: relative;
}
.titleContainer {
position: absolute;
left: 50%;
top: calc(100%);
transform: translateX(-50%);
padding-top: 8px;
}
.titleWrapper {
padding: 0.25rem;
background-color: #afafaf;
color: #fff;
border-radius: 0.25rem;
}
Why this works well:
- No layout shifts
- No DOM measurements
- No scroll listeners
- No portal complexity
Top comments (0)