Mikhail Petrov
4 min readNov 6, 2023

Global react tooltip for clipped text with ellipsis

Every frontend developer faces the issue of not having enough space to show all the text in the container.

There is a common CSS solution:

.ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

Looks better, isn’t it? But what is the full text? In many cases it’s important for the user to know the full text. Ok, we can implement a custom <EllipsisText/> component, pass a text as children and track a hover event to show tooltip.

<EllipsisText>
elit duis ut duis Lorem excepteur exercitation esse velit culpa sit
</EllipsisText>

Now we have a tooltip, but it is shown even when the text is not clipped. The ellipsis appears when offsetWidth of the element is less then scrollWidth.

Great! We have a component which can render a text with ellipsis and show a tooltip.

const TodoItem = ({title}) => (
<List.Item>
<Typography.Title level={5}>
<EllipsisText>
{title}
</EllipsisText>
</Typography.Title>
</Liet.Item>
);

Every place of the code should be wrapped with <EllipsisText/> component. This component wrapper is extra React node and extra DOM element.

Simple component which renders a list of entity properties

const TodoItem = ({date, description, state, title}) => (
<Description>
<Property title='title'>
<TodoStateIcon state={state} />
{title}
</Property>
<Property title='description'>
{description}
</Property>
<Property title='Date'>
{date.format('LL LTS')}
</Property>
<Property title='state'>
{formatState(state)}
</Property>
</Description>
);

becomes a mess of multiple <EllipsisText/> wrappers

const TodoItem = ({date, description, state, title}) => (
<Description>
<Property title='title'>
<EllipsisText>
<TodoStateIcon state={state} />
{title}
</EllipsisText>
</Property>
<Property title='description'>
<EllipsisText>
{description}
</EllipsisText>
</Property>
<Property title='Date'>
<EllipsisText>
{date.format('LL LTS')}
</EllipsisText>
</Property>
<Property title='state'>
<EllipsisText>
{formatState(state)}
</EllipsisText>
</Property>
</Description>
);

Global tooltip for text with ellipsis

The suggestion is to use a component which tracks all mouse-hovers on elements with ellipsis and shows a tooltip if the text is clipped. In the <TodoItem/> we can just add CSS styles with ellipsis for property values without any changes in initial component code.

EllipsisTooltips component

Add a listener to document’s mouseover event to track all child-hover events.

  React.useEffect(() => {
const mouseOverHandler = (e) => setHoveredElement(e.target);

document.addEventListener('mouseover', mouseOverHandler);
return () => {
document.removeEventListener('mouseover', mouseOverHandler);
}
}, [setHoveredElement]);

Hover event can be raised on child of element with ellipsis, so we need to check target and all its ancestors for being clipped text.

function findBaseTooltipElement(element) {
const elementStyle = getComputedStyle(element);
if (isStyleWithEllipsis(elementStyle)) {
return isOverflowX(element) ? element : null;
}
if (isStyleWithClamp(elementStyle)) {
return isOverflowY(element) ? element : null;
}
return element.parentElement ? findBaseTooltipElement(element.parentElement) : null;
}

The component tracks text with ellipsis and text with limited number of lines by using the CSS line-clamp property.

const isStyleWithEllipsis = (style) => style.overflowX === 'hidden' &&
style.textOverflow === 'ellipsis' &&
style.whiteSpace === 'nowrap';

const isStyleWithClamp = (style) => style['-webkit-line-clamp'] > 0;

const isOverflowX = (element) => element.offsetWidth < element.scrollWidth;

const isOverflowY = (element) => element.offsetHeight < element.scrollHeight;

setHoveredElement from mouseover listener checks that new element is not a child of already hovered element with a shown tooltip

const setHoveredElement = React.useCallback((hoveredElement) => {
if (tooltipBaseElement.current && tooltipBaseElement.current !== hoveredElement && !tooltipBaseElement.current.contains(hoveredElement)) {
defineTooltipBaseElement.cancel();
tooltipBaseElement.current = null;
onShowTooltip?.(null);
}
defineTooltipBaseElement(hoveredElement);
}, [onShowTooltip, defineTooltipBaseElement]);

and calls defineTooltipBaseElement function which tries to find should the hovered element have a tooltip. The function is debounced to avoid tooltip flickering and delay tooltip show.

const defineTooltipBaseElement = React.useCallback(debounce((element) => {
const baseElement = findBaseTooltipElement(element);
tooltipBaseElement.current = baseElement;
onShowTooltip?.(baseElement);
}, debounceTimeMilliseconds), []);

The full code of <EllipsisTooltips/> component

import React from 'react';
import { debounce } from 'lodash';

function findBaseTooltipElement(element) {
const elementStyle = getComputedStyle(element);
if (isStyleWithEllipsis(elementStyle)) {
return isOverflowX(element) ? element : null;
}
if (isStyleWithClamp(elementStyle)) {
return isOverflowY(element) ? element : null;
}
return element.parentElement ? findBaseTooltipElement(element.parentElement) : null;
}

const isStyleWithEllipsis = (style) => style.overflowX === 'hidden' &&
style.textOverflow === 'ellipsis' &&
style.whiteSpace === 'nowrap';

const isStyleWithClamp = (style) => style['-webkit-line-clamp'] > 0;

const isOverflowX = (element) => element.offsetWidth < element.scrollWidth;

const isOverflowY = (element) => element.offsetHeight < element.scrollHeight;

export const EllipsisTooltips = React.memo(({
debounceTimeMilliseconds = 300,
onShowTooltip
}) => {
const tooltipBaseElement = React.useRef(null);

const defineTooltipBaseElement = React.useCallback(debounce((element) => {
const baseElement = findBaseTooltipElement(element);
tooltipBaseElement.current = baseElement;
onShowTooltip?.(baseElement);
}, debounceTimeMilliseconds), []);

const setHoveredElement = React.useCallback((hoveredElement) => {
if (tooltipBaseElement.current && tooltipBaseElement.current !== hoveredElement && !tooltipBaseElement.current.contains(hoveredElement)) {
defineTooltipBaseElement.cancel();
tooltipBaseElement.current = null;
onShowTooltip?.(null);
}
defineTooltipBaseElement(hoveredElement);
}, [onShowTooltip, defineTooltipBaseElement]);

React.useEffect(() => {
const mouseOverHandler = (e) => setHoveredElement(e.target);

document.addEventListener('mouseover', mouseOverHandler);
return () => {
document.removeEventListener('mouseover', mouseOverHandler);
}
}, [setHoveredElement]);

return null;
});

Developer should just define an onShowTooltip callback in props to show a tooltip. Every DOM node with clipped text with ellipsis will have a tooltip on hover.

Demo shows complex content in first block with ellipsis, 10 random filled blocks with line-clamp and 10 random filled blocks with ellipsis.

Github repository with example is here.

Codesandbox playground