Sitemap
Octavian Silviu

Octavian Silviu

Dynamic Ellipsis Tooltip — Leverage the power of Angular Directives, Mutation Observers, and Resize Observers to dynamically add a tooltip to truncated text.

5 min readOct 27, 2025

Displaying text in a limited space often leads to truncation — usually indicated by an ellipsis (“…”). A common UX improvement is to show the full text in a tooltip when truncation occurs.
In this article, we’ll explore how to dynamically detect when text is truly truncated and apply a tooltip only when needed, using the power of Angular Directives, Mutation Observers, and Resize Observers.

Why is there a need for the Mutation and Resize observer?
Well, we want to ensure that each time the content changes its text, we re-evaluate if the tooltip is needed and detach/re-attach it. This is done through the Mutation Observer, which lets us ‘observe’ when a node has been modified.

On the other hand, the resize observer is an API that notifies us whenever a node changes its width. This will help us re-evaluate the need to display the tooltip in case a change in size has occurred.

With that being said, let’s start with two custom directives for the APIs mentioned above.

import {
AfterViewInit,
Directive,
ElementRef,
inject,
OnDestroy,
OnInit,
output
} from '@angular/core';
@Directive({
selector: '[appResize]'
})
export class ResizeDirective implements OnInit, AfterViewInit, OnDestroy {
appResize = output<Element>();
private resizeObserverInstance!: ResizeObserver;
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);

ngOnInit(): void {
this.createResizeObserver();
}

ngAfterViewInit(): void {
this.resizeObserverInstance.observe(this.elementRef.nativeElement);
}

ngOnDestroy(): void {
this.resizeObserverInstance.disconnect();
}

private createResizeObserver(): void {
this.resizeObserverInstance = new ResizeObserver((entries) =>
this.appResize.emit(entries[0].target)
);
}
}

In the code above, we create the Resize Observer, observe the element ref, and disconnect it when the directive(host element) is destroyed.

Similarly, with the Mutation Observer:

import {
AfterViewInit,
Directive,
ElementRef,
inject,
input,
OnInit,
output
} from '@angular/core';
@Directive({
selector: '[appMutate]'
})
export class MutateDirective implements OnInit, AfterViewInit {
readonly mutationConfig = input<MutationObserverInit>({
characterData: true,
subtree: true
});

readonly appMutate = output<MutationRecord[]>();

private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly mutationObserverInstance!: MutationObserver;

ngOnInit(): void {
this.createMutatationObserver();
}

ngAfterViewInit(): void {
this.mutationObserverInstance.observe(
this.elementRef.nativeElement,
this.mutationConfig()
);
}

ngOnDestroy(): void {
this.mutationObserverInstance.disconnect();
}

private createMutatationObserver(): void {
this.mutationObserverInstance= new MutationObserver(
(mutations: MutationRecord[]) => this.appMutate.emit(mutations)
);
}
}

Let me explain what the values of the mutationConfig do:

  • subtree: true: Observe not just the target node, but all of its descendants as well (children, grandchildren, etc).
    This is not all the time needed, but it’s good in case we have nested children and not just plain text.
  • characterData: true: Observe changes to the text content (character data) inside the element (or inside any of its descendants if subtree: true).
    This is required for our use case because we want to observe the changes in text content.

To display the tooltip, I will use tippy.js

npm i tippy.js

Let’s create a tooltip.constants.ts file and paste the following:

import { Props } from 'tippy.js';
export const tooltipDefaultSettings = {
arrow: false,
hideOnClick: true,
placement: 'bottom',
duration: [0, 200],
animation: 'scale',
theme: 'custom-tooltip-theme'
} satisfies Partial<Props>;

Here’s a link with all the props available. I added a theme property with the value of ‘custom-tooltip-theme’ to style our tooltip.

Get Octavian Silviu’s stories in your inbox

Join Medium for free to get updates from this writer.

Now the dynamic ellipsis directive:

import {
AfterViewInit,
Directive,
ElementRef,
inject,
input,
OnDestroy
} from '@angular/core';
import { tooltipDefaultSettings } from './tooltip.constants';
import tippy, { Instance, Props } from 'tippy.js';
import { MutateDirective } from './mutate.directive';
import { ResizeDirective } from './resize.directive';
type TooltipPositionType = 'bottom' | 'top' | 'left' | 'right';
@Directive({
selector: '[appEllipsisTooltip]',
host: {
'(appMutate)': 'recomputeTooltip()',
'(appResize)': 'recomputeTooltip()'
},
hostDirectives: [
{
directive: MutateDirective,
outputs: ['appMutate']
},
{
directive: ResizeDirective,
outputs: ['appResize']
}
]
})
export class EllipsisTooltipDirective implements AfterViewInit, OnDestroy {
readonly tooltipPosition = input<TooltipPositionType>();

private readonly elementRef = inject(ElementRef);
private tooltipInstance: any;

ngAfterViewInit(): void {
this.createTooltipIfTruncated();
}

ngOnDestroy(): void {
this.destroyTooltip();
}

recomputeTooltip(): void {
this.destroyTooltip();
this.createTooltipIfTruncated();
}

private createTooltipIfTruncated(): void {
if (!this.tooltipInstance && this.isTruncated()) {
this.tooltipInstance = this.createTooltip();
}
}

private isTruncated(): boolean {
const element = this.elementRef.nativeElement;
return element.offsetWidth < element.scrollWidth;
}

private createTooltip(): Instance<Props>[] {
const props: Partial<Props> = {
...tooltipDefaultSettings,
trigger: 'mouseenter',
content: this.elementRef.nativeElement.textContent.trim(),
placement: this.tooltipPosition() ?? 'bottom'
};
return tippy(this.elementRef.nativeElement, props);
}

private destroyTooltip(): void {
this.tooltipInstance?.destroy();
this.tooltipInstance = null;
}
}

Quite a lot is going on, so let’s break down the code:

  • The first thing we do is to apply the two directives we created earlier on the host element using the hostDirective property and listen to the events emitted by each on the host property, and call the recomputeTooltip method.
  • recomputeTooltip method destroys if the tooltip has already been created and creates it only if it’s needed.
  • isTruncated method checks if the text has ellipsis
  • createTooltip method creates the tooltip and returns the instance.

Finally, we create this mixin and import it into styles.scss:

@mixin tooltip-theme {
.tippy-box[data-theme~="custom-tooltip-theme"] {
@apply break-words rounded-md border border-solid border-border bg-background p-2 text-sm leading-4 shadow-sm;
}
}

I used TailwindCSS to style it, but you can use plain CSS/SCSS.

Let’s see it in action!
I created a page with a text field that would have an ellipsis after 128px and an input that would allow us to modify in real time the value of the text.
On hover, the tooltip is shown.

Let’s modify the value, and you will see that there’s no tooltip applied anymore.

So that is the Mutation Observer in action.
To see how the Resize Observer acts, we will add a button that would shrink the text width.

You can see that the ellipsis is applied after two characters, and the tooltip is shown on hover! :)

--

--