ngx-overflow-tooltip
v1.0.0
Published
Angular standalone directive that detects when an element's text is ellipsized — exposes a reactive isTruncated signal so a tooltip (Material or anything else) only shows when the content actually overflows.
Downloads
301
Maintainers
Readme
ngx-overflow-tooltip
A reactive Angular standalone directive that detects when a host element's text
is ellipsized / truncated and exposes a typed isTruncated signal — so a
tooltip, "Read more" button, or any other reveal affordance only shows when the
visible content is actually clipped. Built for Angular 21 / signals / zoneless.
No @angular/material peer dependency.

Why this exists
MatTooltip (and most other tooltip surfaces) doesn't know whether its host
element's text is actually clipped. So a common UI papercut: tooltips fire on
every list row, even the short ones whose text fits comfortably. This directive
plugs that gap.
The closest existing Angular package (ngx-ellipsis-tooltip) hasn't been touched since 2022 (Angular 11). The closely-named ngx-ellipsis does the opposite job — it forces multi-line truncation rather than detecting it.
Features
- Standalone directive — no module to register, no peer dep on
@angular/materialor any third party - Reactive
isTruncatedsignal output — read it from a template via theexportAsref, or from code - Three modes —
auto(default, both axes),single(horizontal only),multi(vertical only); the bare attribute defaults toauto - Re-measures via
ResizeObserveron layout changes andMutationObserveron content changes — handles dynamic text and hot-swapped DOM children - rAF-coalesced — back-to-back observer callbacks collapse into a single measurement pass
- Runs measurements outside the Angular zone; only the signal flip re-enters when the value actually changes
- SSR-safe — skips wiring entirely when
ResizeObserveris missing - Works with zoneless Angular (
provideExperimentalZonelessChangeDetection) truncatedChangeoutput for code-side consumers- Tree-shakable (
sideEffects: false)
At a glance
| | |
| ----------------------------------------------------------- | ------------------------------------------------------------------ |
| Single-line rows
| Multi-line clamp
|
| Live content
| |
Install
npm install ngx-overflow-tooltipPeer dependencies: @angular/common, @angular/core (both >=21.0.0 <22.0.0).
Usage
The directive is headless — bring your own reveal affordance.
With Angular Material MatTooltip
import { Component } from '@angular/core'
import { MatTooltipModule } from '@angular/material/tooltip'
import { OverflowTooltipDirective } from 'ngx-overflow-tooltip'
@Component({
selector: 'app-row',
standalone: true,
imports: [OverflowTooltipDirective, MatTooltipModule],
template: `
<span
ngxOverflowTooltip
#oft="ngxOverflowTooltip"
class="single-line"
[matTooltip]="fullText"
[matTooltipDisabled]="!oft.isTruncated()"
>
{{ fullText }}
</span>
`,
styles: [
`
.single-line {
display: inline-block;
max-width: 240px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`
]
})
export class RowComponent {
readonly fullText = 'Engineering · Platform · Observability · 2026 cohort'
}The MatTooltip only fires when oft.isTruncated() is true. No tooltip on
rows whose text fits.
With a plain title attribute (no third-party deps)
<span
ngxOverflowTooltip
#oft="ngxOverflowTooltip"
class="single-line"
[title]="oft.isTruncated() ? fullText : ''"
>
{{ fullText }}
</span>"Read more" toggle on multi-line content
<p [ngxOverflowTooltip]="'multi'" #oft="ngxOverflowTooltip" class="clamp-3">
{{ longDescription }}
</p>
@if (oft.isTruncated() || expanded()) {
<button type="button" (click)="expanded.set(!expanded())">
{{ expanded() ? 'Show less' : 'Read more' }}
</button>
}.clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}Reading the signal from a parent component
@Component({
imports: [OverflowTooltipDirective],
template: `
<span ngxOverflowTooltip (truncatedChange)="onTruncated($event)">
{{ text }}
</span>
`
})
export class ParentComponent {
onTruncated(value: boolean): void {
// fires only when the flag flips
}
}Or grab the directive instance via viewChild:
import { viewChild } from '@angular/core'
readonly oft = viewChild.required(OverflowTooltipDirective)
constructor() {
effect(() => {
if (this.oft().isTruncated()) {
// …
}
})
}API
Inputs
| Input | Type | Default | Description |
| -------------------- | ------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ngxOverflowTooltip | 'auto' \| 'single' \| 'multi' | 'auto' | The detection mode. Aliased onto the selector so you can pass it inline: [ngxOverflowTooltip]="'single'". The bare attribute (<span ngxOverflowTooltip>) means auto. |
Outputs
| Output | Type | Description |
| ----------------- | --------- | ----------------------------------------------------------------------------------------- |
| truncatedChange | boolean | Emits only when the truncation flag flips, not on every observer fire. Stays in sync. |
Public surface
| Member | Type | Description |
| ----------------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
| mode | Signal<OverflowTooltipMode> | The active mode signal — read-only from outside. |
| isTruncated | Signal<boolean> | The truncation flag. Read from a template via the exportAs ref or from code via viewChild. |
| truncatedChange | OutputEmitterRef<boolean> | Subscribable change output (see above). |
exportAs
<span ngxOverflowTooltip #oft="ngxOverflowTooltip"> {{ text }} </span>#oft is a reference of type OverflowTooltipDirective that the rest of the
template can read.
Detection modes
| Mode | Compares | When to use |
| -------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| auto | scrollWidth > clientWidth or scrollHeight > clientHeight | Default — covers single-line ellipsis and multi-line clamps in one binding. |
| single | scrollWidth > clientWidth | A single-line text node where vertical overflow would never happen anyway. |
| multi | scrollHeight > clientHeight | A clamped paragraph (-webkit-line-clamp) where horizontal overflow is irrelevant. |
How it measures
- On
afterNextRender, the directive attaches aResizeObserverto the host element so layout changes (window resize, container reflow, font load) re-trigger a check. - It also attaches a
MutationObserverwith{ childList, subtree, characterData }so changes to the rendered text — including when the host's bound text expression changes — re-trigger a check too. The directive this lib was extracted from only reacted to resize, which missed the dynamic-content case. - Both observer callbacks fire outside the Angular zone — measurements
don't tick the change-detector unless they have to. The callbacks coalesce
into a single
requestAnimationFrameso a burst of mutations runs one measurement. - The measurement compares
scrollWidthvsclientWidthand / orscrollHeightvsclientHeightbased onmode(). Only when the result actually flips does the signalset(andtruncatedChangeemit) — so downstream consumers don't get spammed by every observer fire. The initial measurement (right afterafterNextRender) and the re-check that follows amodeinput change run on the standard Angular schedule rather than out-of-zone, since they're driven by render lifecycle and signal effects. - On destroy, both observers disconnect and any pending rAF is cancelled.
SSR / older browsers
If ResizeObserver is undefined at construction time (typical for SSR
pre-render), the directive skips wiring entirely and leaves isTruncated() at
false. Hydration on the client side then runs the normal measurement path.
License
MIT License — Copyright (c) 2026 Dino Klicek
