Skip to main content

Tooltip tools

· 16 min read
Docux
Curious explorer, a bit of a mad experimenter, and a bit of a contributor.

Developer Developement License: MIT

Tooltips are everywhere on the web. They are essential for providing contextual information without cluttering the interface. But creating one that is robust, aesthetically pleasing, and above all, accessible, is a real challenge.

In this article, I will introduce the Tooltip component I developed for Docusaurus. It doesn't just display a simple information box; it integrates intelligent positioning logic, an arrow that always stays aligned, and great customization flexibility.

Key Features

Before diving into the demo, here is an overview of the features that make this component so cool.

1. Smart Positioning (Flipping)

The component is smart enough to know if it has enough space to be displayed. If you ask for a tooltip to appear at the top (position="top") but it is too close to the top edge of the screen, it will automatically flip to the bottom. This ensures the content always remains visible to the user.

2. Always-Aligned Arrow

This is the killer detail! The small arrow of the tooltip isn't just centered. It is dynamically calculated in JavaScript to point precisely to the center of the trigger element, even when the tooltip is shifted by the screen edges. No more arrows pointing into empty space!

3. Advanced Customization

The Tooltip component comes with several predefined "models" for common use cases (info, success, warning, error), but it also allows for full customization via the style prop. You can even insert rich content, such as images or other React components.

4. Accessibility (a11y)

A good component must be accessible to everyone. The tooltip is linked to its trigger element via the aria-describedby attributes, it is focusable, and can be closed with the Escape key.

Component Code

Open for look component's code
import React, {
useState,
useRef,
useEffect,
useCallback,
useMemo,
} from "react";
import ReactDOM from "react-dom";
import styles from "./styles.module.css";
import * as tooltipModels from "./models";

// SSR-safe layout effect hook.
// Uses useLayoutEffect on the client and useEffect on the server to prevent warnings.
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : useEffect;

// Hook to generate a unique and stable ID during the component's lifecycle.
// Essential for accessibility, especially for linking the trigger to the tooltip via `aria-describedby`.
const useUniqueId = (prefix = "id") => {
const idRef = useRef(`${prefix}-${Math.random().toString(36).slice(2, 11)}`);
return idRef.current;
};

// Debounce hook to limit the calling frequency of a function.
// Used here to optimize position recalculations on scroll or resize.
const useDebounce = (fn, wait) => {
const timeout = useRef(null);
const cb = useCallback(
(...args) => {
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => fn(...args), wait);
},
[fn, wait]
);

useEffect(() => {
return () => clearTimeout(timeout.current);
}, []);

return cb;
};

// Utility to parse a pixel value (string or number) into a number.
const parsePx = (v) => {
if (v == null) return 0;
if (typeof v === "number") return v;
const m = String(v).match(/([0-9.]+)/);
return m ? Number(m[1]) : 0;
};

// Utility to recursively extract the raw text from a React component's children.
const extractChildrenInnerText = (node) => {
if (typeof node === "string") return node;
if (Array.isArray(node))
return node.map(extractChildrenInnerText).join(" ");
if (React.isValidElement(node))
return extractChildrenInnerText(node.props.children);
return "";
};

/**
* A flexible and accessible Tooltip component.
* Displays a tooltip on hover or focus of a trigger element.
* Positioning is dynamic to stay within the viewport, and the arrow is always aligned.
* @param {object} props The component props.
* @param {React.ReactNode} props.children The content of the tooltip.
* @param {string} props.text The trigger text, which also serves as the tooltip's title.
* @param {('info'|'success'|'warning'|'error'|'teacher'|'suricate'|object|Function)} [props.model=null] Pre-defined style model, a custom style object, or a function returning a style object.
* @param {'top'|'bottom'|'left'|'right'} [props.position='top'] Preferred tooltip position. It will flip automatically if there's not enough space.
* @param {number} [props.delay=200] Delay in milliseconds before the tooltip is shown.
* @param {number} [props.offset=10] Distance in pixels between the trigger element and the tooltip.
* @param {React.CSSProperties} [props.style={}] Custom CSS styles to apply to the tooltip, overriding model styles.
* @param {boolean} [props.shadow=true] Toggles the box-shadow on the tooltip.
* @param {boolean} [props.block=false] If true, the trigger element will be a block-level element (`display: block`).
*/
const Tooltip = ({
children,
text,
model = null,
position = "top",
delay = 200,
offset = 10,
style = {},
shadow = true,
block = false,
}) => {
// --- Component States ---
const [visible, setVisible] = useState(false); // Tooltip visibility
const [coords, setCoords] = useState({ top: 0, left: 0 }); // Tooltip coordinates (top, left)
const [currentPosition, setCurrentPosition] = useState(position); // Actual position after calculation (may differ from `position`)
const [arrowCoords, setArrowCoords] = useState({}); // Arrow coordinates for perfect alignment
const [isMounted, setIsMounted] = useState(false); // Tracks mounting for client-side only rendering (portal)
const [isTouch, setIsTouch] = useState(false); // Detects a touch-enabled environment

// --- Refs ---
const triggerRef = useRef(null); // Ref to the trigger element
const tooltipRef = useRef(null); // Ref to the tooltip itself
const timerRef = useRef(null); // Ref for the delay timer

// --- IDs ---
const tooltipId = useUniqueId("tooltip"); // Unique ID for accessibility

// Effect to mark the component as mounted (client-side)
useEffect(() => {
setIsMounted(true);
// Clean up the timer if the component unmounts
return () => clearTimeout(timerRef.current);
}, []);

// --- Show/Hide Management ---

// Shows the tooltip after the specified delay
const showTooltip = useCallback(() => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setVisible(true), delay);
}, [delay]);

// Hides the tooltip immediately
const hideTooltip = useCallback(() => {
clearTimeout(timerRef.current);
setVisible(false);
}, []);

// --- Positioning Logic ---

// Calculates the optimal position for the tooltip and its arrow
const computePosition = useCallback(() => {
if (!triggerRef.current || !tooltipRef.current) return null;

const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const { scrollX, scrollY, innerWidth, innerHeight } = window;

let bestPosition = position;

// --- Flipping Logic ---
// If the preferred position doesn't fit in the viewport, try the opposite position.

// Vertical flipping
if (position === 'top' && triggerRect.top - tooltipRect.height - offset < 0) {
if (triggerRect.bottom + tooltipRect.height + offset <= innerHeight) {
bestPosition = 'bottom';
}
} else if (position === 'bottom' && triggerRect.bottom + tooltipRect.height + offset > innerHeight) {
if (triggerRect.top - tooltipRect.height - offset >= 0) {
bestPosition = 'top';
}
}

// Horizontal flipping
if (position === 'left' && triggerRect.left - tooltipRect.width - offset < 0) {
if (triggerRect.right + tooltipRect.width + offset <= innerWidth) {
bestPosition = 'right';
}
} else if (position === 'right' && triggerRect.right + tooltipRect.width + offset > innerWidth) {
if (triggerRect.left - tooltipRect.width - offset >= 0) {
bestPosition = 'left';
}
}

setCurrentPosition(bestPosition); // Update the actual used position

let top, left;

// Calculate top/left coordinates based on the best position
switch (bestPosition) {
case "bottom":
top = triggerRect.bottom + scrollY + offset;
left =
triggerRect.left +
scrollX +
triggerRect.width / 2 -
tooltipRect.width / 2;
break;
case "left":
top =
triggerRect.top +
scrollY +
triggerRect.height / 2 -
tooltipRect.height / 2;
left = triggerRect.left + scrollX - tooltipRect.width - offset;
break;
case "right":
top =
triggerRect.top +
scrollY +
triggerRect.height / 2 -
tooltipRect.height / 2;
left = triggerRect.right + scrollX + offset;
break;
case "top":
default:
top = triggerRect.top + scrollY - tooltipRect.height - offset;
left =
triggerRect.left +
scrollX +
triggerRect.width / 2 -
tooltipRect.width / 2;
break;
}

// --- Collision Prevention (Clamping) ---
// Ensures the tooltip never overflows the viewport edges.
if (left < scrollX) left = scrollX + offset;
if (left + tooltipRect.width > scrollX + innerWidth)
left = scrollX + innerWidth - tooltipRect.width - offset;
if (top < scrollY) top = scrollY + offset;
if (top + tooltipRect.height > scrollY + innerHeight)
top = scrollY + innerHeight - tooltipRect.height - offset;

// --- Arrow Position Calculation ---
// Aligns the arrow with the center of the trigger, even if the tooltip is shifted.
const ARROW_WIDTH = 8;
const ARROW_HEIGHT = 8;
const newArrowCoords = {};
if (bestPosition === 'top' || bestPosition === 'bottom') {
const arrowLeft = (triggerRect.left + scrollX + triggerRect.width / 2) - left - (ARROW_WIDTH / 2);
newArrowCoords.left = `${arrowLeft}px`;
} else { // 'left' or 'right'
const arrowTop = (triggerRect.top + scrollY + triggerRect.height / 2) - top - (ARROW_HEIGHT / 2);
newArrowCoords.top = `${arrowTop}px`;
}
setArrowCoords(newArrowCoords);

return { top, left };
}, [offset, position, setArrowCoords]);

// Updates the tooltip's position
const updatePosition = useCallback(() => {
const newCoords = computePosition();
if (newCoords) setCoords(newCoords);
}, [computePosition]);

// --- Event Observers ---

// Recalculates position on scroll or resize
const debouncedUpdate = useDebounce(updatePosition, 20);
useIsomorphicLayoutEffect(() => {
if (!visible) return;
updatePosition(); // Initial update

window.addEventListener("scroll", debouncedUpdate, { passive: true });
window.addEventListener("resize", debouncedUpdate);

// ResizeObserver is more performant for detecting element size changes
let ro;
if (window.ResizeObserver && (triggerRef.current || tooltipRef.current)) {
ro = new ResizeObserver(debouncedUpdate);
if (triggerRef.current) ro.observe(triggerRef.current);
if (tooltipRef.current) ro.observe(tooltipRef.current);
}

return () => {
window.removeEventListener("scroll", debouncedUpdate);
window.removeEventListener("resize", debouncedUpdate);
if (ro) ro.disconnect();
};
}, [visible, updatePosition, debouncedUpdate]);

// Hides the tooltip with the "Escape" key
useEffect(() => {
if (!visible) return;
const handleKey = (e) => e.key === "Escape" && hideTooltip();
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [visible, hideTooltip]);

// Detects if the environment is touch-enabled to adapt behavior (on click)
useEffect(() => {
const handler = () => setIsTouch(true);
window.addEventListener("touchstart", handler, { once: true });
return () => window.removeEventListener("touchstart", handler);
}, []);

// --- Event Handlers for the Trigger ---
const onClick = useCallback(
() => (isTouch ? (visible ? hideTooltip() : showTooltip()) : null),
[isTouch, visible, showTooltip, hideTooltip]
);

const triggerElement = (
<span
ref={triggerRef}
aria-describedby={visible ? tooltipId : undefined}
tabIndex={0} // Makes the element focusable
style={{ display: block ? "block" : "inline-block" }}
onClick={onClick}
onMouseEnter={!isTouch ? showTooltip : undefined}
onMouseLeave={!isTouch ? hideTooltip : undefined}
onFocus={showTooltip}
onBlur={hideTooltip}
className="text--decoration text--italic text--primary"
>
{text}
</span>
);

// --- Tooltip Styles and Content ---

// Merges model styles and custom styles
const { finalStyle, mergedStyle } = useMemo(() => {
const modelStyle =
typeof model === "string"
? tooltipModels[model] || {}
: typeof model === "function"
? model(style) || {}
: typeof model === "object"
? model
: {};

const _mergedStyle = { ...modelStyle, ...style };
const imageSize = parsePx(_mergedStyle.imageSize || 0);
const imageRight = parsePx(_mergedStyle.imageRight || 0);
const imageBottom = parsePx(_mergedStyle.imageBottom || 0);

const _finalStyle = { ..._mergedStyle };
if (_mergedStyle.image) {
_finalStyle.paddingRight ??= `${imageSize + imageRight + 12}px`;
_finalStyle.paddingBottom ??= `${imageSize + imageBottom + 8}px`;
}
return { finalStyle: _finalStyle, mergedStyle: _mergedStyle };
}, [model, style]);

// Prepares the content (title and body) of the tooltip
const { contentTop, contentMainNodes } = useMemo(() => {
// The title is the `text` prop.
const _contentTop = (typeof text === "string" && text.trim()) ? text : null;

// The main content is the `children` prop.
const _contentMain = children;

// If `children` is a plain string, process it to create paragraphs.
// Otherwise, render it directly (assuming it's JSX).
const _contentMainNodes =
typeof _contentMain === "string"
? _contentMain
.split(/\n\s*\n/)
.map((p, idx) => (
<p key={idx} className={styles.paragraph}>
{p}
</p>
))
: _contentMain;

return { contentTop: _contentTop, contentMainNodes: _contentMainNodes };
}, [children, text]);

// --- Tooltip Rendering via a Portal ---
// The React portal renders the tooltip at the root of `document.body`
// to avoid z-index and clipping issues from parent containers.
const TooltipContent = (
<div
ref={tooltipRef}
id={tooltipId}
role="tooltip"
aria-hidden={!visible}
className={`${styles.tooltip} ${finalStyle.image ? styles.withImage : ""} ${visible ? styles.visible : ""}`}
style={{
top: coords.top,
left: coords.left,
...finalStyle,
boxShadow: shadow
? style.boxShadow || "0 4px 10px rgba(0,0,0,0.2)"
: "none",
position: "absolute",
zIndex: 9999,
pointerEvents: visible ? "auto" : "none",
}}
data-position={currentPosition}
>
<div className={styles.contentWrapper}>
{contentTop && (
<div className={styles.contentTop}>
<h4 className={styles.contentTopTitle}>{contentTop}</h4>
</div>
)}
<div className={styles.contentMainRow}>
<div className={styles.contentMain}>{contentMainNodes}</div>
{mergedStyle.image && (
<img
src={mergedStyle.image}
alt={mergedStyle.imageAlt ?? ""}
aria-hidden={mergedStyle.imageAlt ? "false" : "true"}
className={styles.modelImage}
style={{
width: mergedStyle.imageSize || "64px",
bottom: mergedStyle.imageBottom || "8px",
right: mergedStyle.imageRight || "8px",
}}
/>
)}
</div>
</div>
<div
className={styles.arrow}
style={{
...arrowCoords, // Apply the dynamic position of the arrow
background:
mergedStyle.backgroundColor ||
mergedStyle.background ||
"#333",
boxShadow:
mergedStyle.boxShadow ||
(shadow ? "0 4px 10px rgba(0,0,0,0.2)" : "none"),
border: mergedStyle.border,
}}
/>
</div>
);

return (
<>
{triggerElement}
{isMounted && ReactDOM.createPortal(TooltipContent, document.body)}
</>
);
};

export default Tooltip;

Open for look code for use models
// Predefined tooltip models for easy reuse

export const info = {
backgroundColor: '#2196F3', // Blue
color: '#FFFFFF',
};

export const success = {
backgroundColor: '#4CAF50', // Green
color: '#FFFFFF',
};

export const warning = {
backgroundColor: '#FFC107', // Yellow
color: '#000000',
};

export const error = {
backgroundColor: '#F44336', // Red
color: '#FFFFFF',
};

/**
* A special model to allow full customization.
* @param {React.CSSProperties} styles The custom styles to apply.
* @returns {React.CSSProperties}
*/
export const custom = (styles) => ({
...styles
});

// Teacher model with static image (placed in static/img)
export const teacher = {
backgroundColor: '#0d491fff',
/* fallback shorthand */
background: '#ffffff',
color: '#000000',
border: '2px solid #20190aff',
borderRadius: '8px',
paddingTop: '30px',
paddingLeft: '10px',
// image displayed inside the tooltip (rendered as an inline img to avoid overlap)
image: '/img/toottipsteacherdocuxlab.png',
// decorative by default; set a string to provide accessible alt text
imageAlt: '',
imageSize: '70px',
imageRight: '10px',
imageBottom: '6px',
minWidth: '50px',
};


// Teacher model with static image (placed in static/img)
export const suricate = {
backgroundColor: '#0d491fff',
/* fallback shorthand */
background: '#ffffff',
color: '#000000',
border: '2px solid #20190aff',
borderRadius: '8px',
paddingTop: '30px',
paddingLeft: '10px',
// image displayed inside the tooltip (rendered as an inline img to avoid overlap)
image: '/img/test.webp',
// decorative by default; set a string to provide accessible alt text
imageAlt: '',
imageSize: '70px',
imageRight: '10px',
imageBottom: '6px',
minWidth: '50px',
};

Usage in Docusaurus

One of the great advantages of Docusaurus is its ability to "swizzle" components and make them globally available in MDX files. This is exactly what was done for this Tooltip component.

Thanks to a simple configuration in the src/theme/MDXComponents.js file, the component is imported and added to the list of MDX components.

src/theme/MDXComponents.js
import Tooltip from '@site/src/components/Tooltip';

export default {
// ... other components
Tooltip
};

What does this mean for you? Simply that you never need to import the Tooltip component in your .mdx files. You can use it directly, as if it were a native HTML tag.

Usage Examples

Here is an overview of the different ways to use the Tooltip component.

Positioning

The position prop allows you to choose where the tooltip appears. The options are top, bottom, left, and right. Don't forget that if the tooltip doesn't have enough space, it will automatically reposition itself!

Position Top
Position Bottom
Position Left
Position Right
// Top
<Tooltip text="Position Top" position="top">
Default position.
</Tooltip>

// Bottom
<Tooltip text="Position Bottom" position="bottom">
Appears below.
</Tooltip>

// Left
<Tooltip text="Position Left" position="left">
Appears on the left.
</Tooltip>

// Right
<Tooltip text="Position Right" position="right">
Appears on the right.
</Tooltip>

Style Models

Use the model prop to quickly change the tooltip's appearance. You can create your own reusable models in models.js

Information
Success
Warning
Error
// Info
<Tooltip text="Information" model="info">
This is useful information.
</Tooltip>

// Success
<Tooltip text="Success" model="success">
The operation was successful!
</Tooltip>

// Warning
<Tooltip text="Warning" model="warning">
Be careful here.
</Tooltip>

// Error
<Tooltip text="Error" model="error">
An error has occurred.
</Tooltip>

We can create cool models

DocuxLab
But what is it doing here?
<Tooltip model="teacher" text="DocuxLab" >
My favorite
and the one you will find mostly on this site.
We can really make nice tooltips,
it dusts off the classic info bubble.
<LogoIcon name="docusaurus" size='20' />
</Tooltip>

<Tooltip model="suricate" text="But what is it doing here?" >
My friend Christophe's mascot has sneaked in here.

[@avonture.be](https://avonture.be/)
</Tooltip>

Rich Content and Customization

The true power of this component lies in its ability to accept rich content and custom styles.

Rich Content
Custom Style
// Rich Content
<Tooltip text="Rich Content">
You can use <b>bold</b>, <i>italics</i>, and even links like [this link](https://docusaurus.io).

You can even include other components, and even an image.
<center>
![](/img/tooltipstest.png)
</center>
</Tooltip>

// Custom Style
<Tooltip text="Custom Style" style={{
background: 'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)',
color: 'white',
border: '2px solid white',
borderRadius: '10px',
boxShadow: '0 3px 5px 2px rgba(255, 105, 135, .3)'
}}>
This tooltip has a unique style passed via the <code>style</code> prop.
</Tooltip>

Related posts

Retour en haut